Compare commits

...

225 Commits

Author SHA1 Message Date
jkunz a97c4963d6 v14.1.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m40s
2026-06-05 14:39:09 +00:00
jkunz 62271c1819 feat(workapp-mail): add shared WorkApp mail address binding APIs 2026-06-05 14:34:32 +00:00
jkunz e6b3625256 v14.0.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m42s
2026-06-05 03:28:38 +00:00
jkunz 103680a3a0 fix(proxy-protocol): apply inbound PROXY protocol policies per listener 2026-06-05 03:17:37 +00:00
jkunz ba67e0d208 v14.0.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m13s
2026-06-04 16:01:33 +00:00
jkunz e86fe0df7a BREAKING CHANGE(config): remove legacy config seeding and route reprovisioning 2026-06-04 15:51:09 +00:00
jkunz 71ee2133e4 v13.45.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m20s
2026-06-04 13:39:39 +00:00
jkunz 6c8073b91a feat(network-routes): add route source policy editor 2026-06-04 13:36:02 +00:00
jkunz 17bb63f129 fix(email): relay server-first SMTP banners for generated email routes 2026-06-04 12:06:09 +00:00
jkunz 2ec647cd6c v13.44.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m24s
2026-06-04 10:18:40 +00:00
jkunz 01267cfeb5 fix(db): use smartdata cached document support 2026-06-04 10:13:57 +00:00
jkunz eef053bd66 v13.44.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 18m44s
2026-06-04 03:59:48 +00:00
jkunz ccb4dea91e test(migrations): use example IP in fixtures 2026-06-04 03:54:32 +00:00
jkunz b0b480873f feat(settings): add DB-backed email and RemoteIngress hub settings 2026-06-04 03:46:31 +00:00
jkunz 496dba94b1 v13.43.5
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m46s
2026-06-03 16:10:17 +00:00
jkunz 69dbc29662 fix(deps): bump @serve.zone/catalog to ^2.12.8 2026-06-03 16:06:29 +00:00
jkunz 3bd6d2f2de v13.43.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m50s
2026-06-03 14:28:26 +00:00
jkunz 2c8cc93952 fix(remoteingress): track tunnel streams using summary events 2026-06-03 14:24:43 +00:00
jkunz 3f50518b80 v13.43.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m27s
2026-06-03 11:57:22 +00:00
jkunz 15ca5d137c chore(changelog): consolidate smartproxy dependency entry 2026-06-03 11:54:07 +00:00
jkunz 16a4b04dfb fix(deps): bump @push.rocks/smartproxy to ^27.12.6 2026-06-03 11:53:02 +00:00
jkunz 03b494018a fix(deps): bump @push.rocks/smartproxy to ^27.12.6 2026-06-03 11:43:16 +00:00
jkunz 9c08384df0 v13.43.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m22s
2026-06-03 09:32:11 +00:00
jkunz 9286f56316 fix(route-management): use canonical source bindings 2026-06-03 06:46:38 +00:00
jkunz 1c4caf2b85 v13.43.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m57s
2026-06-03 04:20:56 +00:00
jkunz 4a09b273df fix(dockerignore): ignore generated artifacts and caches in Docker build context 2026-06-03 04:17:02 +00:00
jkunz 4ceb46b509 v13.43.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m6s
2026-06-03 03:29:58 +00:00
jkunz 0aa1cde5eb feat(http-redirects): add derived HTTP-to-HTTPS redirects 2026-06-03 03:24:55 +00:00
jkunz 584782dcb7 v13.42.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m58s
2026-06-02 18:59:20 +00:00
jkunz 810ecf46f8 fix(deps): update Deno import dependencies 2026-06-02 17:38:51 +00:00
jkunz 6d5d23a691 fix(source-policy-compiler): normalize source policy route priorities to stable integers 2026-06-02 17:25:18 +00:00
jkunz c6617c79f5 v13.42.3
Release / build-and-release (push) Successful in 6m49s
Docker (tags) / release (push) Failing after 1s
2026-06-02 15:40:09 +00:00
jkunz 135432260d fix(deps): update dependency versions 2026-06-02 15:40:07 +00:00
jkunz b55d2ac61d v13.42.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m1s
2026-06-02 14:11:18 +00:00
jkunz c88e8e1758 fix(dev-deps): bump @git.zone/tsdocker to ^2.4.1 2026-06-02 14:10:49 +00:00
jkunz 6ee716e4ef v13.42.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m6s
2026-06-02 12:48:16 +00:00
jkunz 1d4ed9af2c fix(deps): bump @serve.zone/remoteingress to ^4.22.5 2026-06-02 12:47:53 +00:00
jkunz d2331fdcbe v13.42.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m27s
2026-06-02 00:29:38 +00:00
jkunz 0e7765c740 feat(source-policy): add ordered route source policies with Gitea preset support 2026-06-02 00:29:13 +00:00
jkunz 1a381df937 v13.41.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m57s
2026-06-01 14:49:38 +00:00
jkunz 38e2f3cee1 fix(deps): update smartproxy and remoteingress 2026-06-01 14:38:34 +00:00
jkunz 4a47460bf1 v13.41.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m50s
2026-05-31 21:06:24 +00:00
jkunz 3679cba3a4 fix(smartacme): prevent SmartAcme startup from blocking router startup 2026-05-31 21:05:34 +00:00
jkunz 3dc0371f7e v13.41.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m5s
2026-05-31 19:42:51 +00:00
jkunz b212662764 feat(remoteingress): add RemoteIngress hub settings management 2026-05-31 19:42:17 +00:00
jkunz 776c65a18c v13.40.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m44s
2026-05-31 16:23:56 +00:00
jkunz 5f6ec63770 fix(deps): bump smartproxy and remoteingress dependencies 2026-05-31 16:23:48 +00:00
jkunz 1b4cc0567f v13.40.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m0s
2026-05-31 15:26:43 +00:00
jkunz 22de50b544 fix(routes): ensure source profiles fully own route security 2026-05-31 15:26:18 +00:00
jkunz 2e3bead40c v13.40.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 19m10s
2026-05-31 11:50:08 +00:00
jkunz 85065b05c8 fix(deps): update smartproxy, remoteingress, and tsdeno dependencies 2026-05-31 11:49:25 +00:00
jkunz 7f7a26fb38 v13.40.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 8m31s
2026-05-30 19:57:32 +00:00
jkunz a089b681c4 feat(monitoring-opsserver-radius): use active connection snapshots for proxy metrics and RADIUS network secrets 2026-05-30 19:57:09 +00:00
jkunz 3e71301bf5 v13.39.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m54s
2026-05-30 18:09:42 +00:00
jkunz 58cc8c0753 feat(remoteingress,radius): add remote ingress performance overrides and update RADIUS integration 2026-05-30 18:09:18 +00:00
jkunz e279814803 v13.38.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m11s
2026-05-30 15:05:32 +00:00
jkunz 6bee2eb172 fix(deps): bump @serve.zone/remoteingress to ^4.22.1 2026-05-30 15:05:16 +00:00
jkunz db8ea99e88 v13.38.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m19s
2026-05-30 13:19:15 +00:00
jkunz 98ccf82af0 fix(deps): update @serve.zone/remoteingress to ^4.22.0 2026-05-30 13:18:48 +00:00
jkunz 0f99525612 v13.38.2
Docker (tags) / release (push) Failing after 16m7s
Release / build-and-release (push) Failing after 14m45s
2026-05-30 11:40:28 +00:00
jkunz 8e707d9c4d fix(deps): bump @serve.zone/remoteingress to ^4.21.1 2026-05-30 11:40:00 +00:00
jkunz 418c825b01 v13.38.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 8m58s
2026-05-30 10:35:31 +00:00
jkunz 75f29af27f fix(deps): update @serve.zone/remoteingress to ^4.21.0 2026-05-30 10:35:02 +00:00
jkunz 4467fe629a fix(deps): bump @serve.zone/remoteingress to ^4.21.0 2026-05-30 10:31:37 +00:00
jkunz 1912feffe5 v13.38.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m45s
2026-05-29 17:57:08 +00:00
jkunz 9077b3dad6 feat(dns): support explicit DNS bind interface configuration 2026-05-29 17:56:33 +00:00
jkunz d09ac51c5b v13.37.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m10s
2026-05-29 15:21:54 +00:00
jkunz 9d7975721d fix(packaging): exclude assets from compiled and published artifacts 2026-05-29 15:21:22 +00:00
jkunz 667d62b456 v13.37.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 4m28s
2026-05-29 14:52:42 +00:00
jkunz 90b1ca8de3 fix(release): configure pnpm registry for release workflow 2026-05-29 14:45:22 +00:00
jkunz 17d824d718 v13.37.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 20s
2026-05-29 14:05:26 +00:00
jkunz 06a8636aee feat(distribution): add binary installer 2026-05-29 13:58:05 +00:00
jkunz 4bf08c1fc3 fix(distribution): sync Deno binary import map 2026-05-29 10:43:12 +00:00
jkunz 7e721c54d0 feat(distribution): add CLI binary distribution and improve DNS challenge handling 2026-05-29 10:38:54 +00:00
jkunz e6aa5a1dd2 v13.36.3
Docker (tags) / release (push) Failing after 1s
2026-05-29 08:42:32 +00:00
jkunz bbe18e1413 fix(deps): bump smartproxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts 2026-05-29 08:42:14 +00:00
jkunz e2a10bdc3c v13.36.2
Docker (tags) / release (push) Failing after 1s
2026-05-29 04:00:16 +00:00
jkunz 42a5f6df7b fix(dns): preserve parallel ACME TXT challenges and mixed-case DNS queries 2026-05-29 03:59:59 +00:00
jkunz c61d832b43 v13.36.1
Docker (tags) / release (push) Failing after 1s
2026-05-28 14:39:36 +00:00
jkunz 872a822ed7 fix(remoteingress): bump @serve.zone/remoteingress to ^4.18.0 2026-05-28 14:38:57 +00:00
jkunz 34bfd1528b v13.36.0
Docker (tags) / release (push) Failing after 1s
2026-05-28 08:48:03 +00:00
jkunz be38808795 feat(network): add top connected ASN activity to network monitoring 2026-05-28 08:47:12 +00:00
jkunz b9ae4ac344 v13.35.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 05:12:13 +00:00
jkunz 37adcc9ddc feat(vpn): use authenticated VPN route grants 2026-05-24 05:11:48 +00:00
jkunz ac118397f9 v13.34.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 23:45:34 +00:00
jkunz 8188b4712c feat(vpn): allow target profiles to grant non-vpnOnly routes by live client source IP 2026-05-21 23:44:01 +00:00
jkunz 27d077feed v13.33.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 01:56:32 +00:00
jkunz 98913c1977 feat(security): add queued IP intelligence observation and filtered retrieval for network and security views 2026-05-21 01:56:17 +00:00
jkunz ca5c57a329 v13.32.1
Docker (tags) / release (push) Failing after 1s
2026-05-20 16:24:44 +00:00
jkunz 707fbc2413 fix(opsserver,vpn): tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules 2026-05-20 16:24:30 +00:00
jkunz a0c9d40e87 fix(deps): update smartproxy for Alpine compatibility 2026-05-20 15:15:34 +00:00
jkunz 2a73973eda fix(deps): update smartdb for Alpine compatibility 2026-05-20 13:46:01 +00:00
jkunz f0069f87e2 v13.32.0
Docker (tags) / release (push) Failing after 1s
2026-05-19 22:24:40 +00:00
jkunz 77c1738390 feat(ops-auth): add scoped API token auth across ops endpoints 2026-05-19 22:24:37 +00:00
jkunz 53d7c5350e v13.31.0
Docker (tags) / release (push) Failing after 1s
2026-05-19 17:06:52 +00:00
jkunz 7986d01245 feat(opsserver): add admin user create/delete management and default hosted idp.global auth support 2026-05-19 17:06:50 +00:00
jkunz 0b01a4c26b v13.30.0
Docker (tags) / release (push) Failing after 1s
2026-05-18 16:09:40 +00:00
jkunz 407c8eef8a feat(docs): document first-admin bootstrap flow and update authentication examples 2026-05-18 16:09:26 +00:00
jkunz aa0ef2f033 v13.29.1
Docker (tags) / release (push) Failing after 1s
2026-05-14 00:43:14 +00:00
jkunz 7819f09625 fix(smartconfig): enable npm publishing in smartconfig 2026-05-14 00:42:58 +00:00
jkunz 3f8c0c4219 v13.29.0
Docker (tags) / release (push) Failing after 1s
2026-05-14 00:37:15 +00:00
jkunz 70fcd46d52 feat(opsserver-admin): add persisted admin bootstrap flow with optional idp.global authentication 2026-05-14 00:30:09 +00:00
jkunz 47a1f5d7db fix(vpn): harden VPN route access and wireguard client configuration handling 2026-05-13 13:42:12 +00:00
jkunz 67b9fb536c v13.28.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 22:35:07 +00:00
jkunz 8dd0c3def9 feat(gateway-clients): add managed gateway client administration and token-bound route ownership 2026-05-09 22:35:07 +00:00
jkunz d73b250382 v13.27.1
Docker (tags) / release (push) Failing after 1s
2026-05-09 20:02:45 +00:00
jkunz 1c1d55ab8a fix(docker): configure pnpm to use the verdaccio registry during Docker builds 2026-05-09 20:02:45 +00:00
jkunz 2596303c06 v13.27.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 17:30:37 +00:00
jkunz f78bddaede feat(api-token-manager): seed and rotate the environment-managed admin API token during initialization 2026-05-09 17:30:37 +00:00
jkunz a2887d6266 v13.26.0
Docker (tags) / release (push) Failing after 1s
2026-05-09 11:53:45 +00:00
jkunz 97505935bb feat(gateway-clients): add policy-based gateway client tokens and gateway client route and DNS management endpoints 2026-05-09 11:53:45 +00:00
jkunz 7e3b89d9b4 fix: remove default dcrouter admin password 2026-05-08 16:24:45 +00:00
jkunz 7bb6559748 docs: refresh readme and legal info 2026-05-07 20:22:12 +00:00
jkunz 5fbe2eb80b feat: add workapp mail sync API 2026-04-29 16:29:38 +00:00
jkunz a22cc1c0eb feat: add workhoster gateway API 2026-04-29 15:18:14 +00:00
jkunz 4ea339b85a fix: modernize docker publishing 2026-04-29 10:03:34 +00:00
jkunz df9cc3e49b v13.25.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 20:49:57 +00:00
jkunz 7f3ab2499d feat(security): compile network ranges and CIDR arrays into edge firewall policies 2026-04-26 20:49:57 +00:00
jkunz 89ab918826 v13.24.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 19:51:08 +00:00
jkunz e5c3578163 feat(security): add security policy management and IP intelligence operations to the ops UI 2026-04-26 19:51:08 +00:00
jkunz 1567606c49 v13.23.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 15:15:27 +00:00
jkunz af31982d58 feat(security): add managed security policies with IP intelligence and remote ingress firewall propagation 2026-04-26 15:15:27 +00:00
jkunz a322308623 v13.22.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 12:14:51 +00:00
jkunz ec5374900c feat(remoteingress): add remote ingress performance configuration and expose tunnel transport metrics 2026-04-26 12:14:51 +00:00
jkunz 49ce265d7e fix(deps): bump @push.rocks/smartproxy to ^27.8.2 2026-04-26 11:32:57 +00:00
jkunz 63729697c5 v13.21.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-26 09:29:29 +00:00
jkunz ce93b726ef fix(deps): bump @push.rocks/smartproxy to ^27.8.1 2026-04-26 09:29:29 +00:00
jkunz 1c3aa89f8d v13.21.0
Docker (tags) / security (push) Failing after 10s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-25 20:37:28 +00:00
jkunz b3751abd17 feat(monitoring): improve network activity metrics with live domain request rates and backend identifiers 2026-04-25 20:37:28 +00:00
jkunz 97017ede98 chore(deps): update serve.zone interfaces 2026-04-25 14:01:26 +00:00
jkunz 4b928b038e v13.20.2
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 14:28:19 +00:00
jkunz a466b88408 fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates 2026-04-17 14:28:19 +00:00
jkunz e26ea9e114 v13.20.1
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 13:43:13 +00:00
jkunz c5ca95b6f5 fix(docs): refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance 2026-04-17 13:43:13 +00:00
jkunz 1f25ca4095 v13.20.0
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 06:17:49 +00:00
jkunz 2891e5d3ee feat(routes): add remote ingress controls and preserve-port targeting for route configuration 2026-04-17 06:17:49 +00:00
jkunz 152110c877 v13.19.1
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-16 22:21:07 +00:00
jkunz d780e02928 fix(routes): preserve inline target ports when clearing network target references 2026-04-16 22:21:07 +00:00
jkunz 8bbaf26813 v13.19.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-15 19:59:04 +00:00
jkunz 39f449cbe4 feat(routes,email): persist system DNS routes with runtime hydration and add reusable email ops DNS helpers 2026-04-15 19:59:04 +00:00
jkunz e0386beb15 v13.18.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 13:11:48 +00:00
jkunz 1d7e5495fa feat(email): add persistent smartmta storage and runtime-managed email domain syncing 2026-04-14 13:11:48 +00:00
jkunz 9a378ae87f v13.17.9
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 09:33:41 +00:00
jkunz 58fbc2b1e4 fix(monitoring): align domain activity metrics with id-keyed route data 2026-04-14 09:33:41 +00:00
jkunz 20ea0ce683 v13.17.8
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 01:16:37 +00:00
jkunz bcea93753b fix(opsserver): align certificate status handling with the updated smartproxy response format 2026-04-14 01:16:37 +00:00
jkunz 848515e424 v13.17.7
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:56:31 +00:00
jkunz 38c9978969 fix(repo): no changes to commit 2026-04-14 00:56:31 +00:00
jkunz ee863b8178 v13.17.6
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:53:26 +00:00
jkunz 9bb5a8bcc1 fix(dns,routes): keep DoH socket-handler routes runtime-only and prune stale persisted entries 2026-04-14 00:53:26 +00:00
jkunz 5aa07e81c7 v13.17.5
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 23:02:42 +00:00
jkunz aec8b72ca3 fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior 2026-04-13 23:02:42 +00:00
jkunz 466654ee4c v13.17.3
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:46:12 +00:00
jkunz f1a11e3f6a fix(ops-view-routes): sync route filter toggle selection via component changeSubject 2026-04-13 19:46:12 +00:00
jkunz e193b3a8eb v13.17.2
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:17:46 +00:00
jkunz 1bbf31605c fix(monitoring): exclude unconfigured routes from domain activity aggregation 2026-04-13 19:17:46 +00:00
jkunz f2cfa923a0 v13.17.1
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:15:46 +00:00
jkunz cdc77305e5 fix(monitoring): stop allocating route metrics to domains when no request data exists 2026-04-13 19:15:46 +00:00
jkunz 835537f789 v13.17.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:12:56 +00:00
jkunz 754b223f62 feat(monitoring,network-ui,routes): add request-based domain activity metrics and split routes into user and system views 2026-04-13 19:12:56 +00:00
jkunz 0a39d50d20 v13.16.2
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 18:51:41 +00:00
jkunz de7b9f7ec5 fix(deps): bump @push.rocks/smartproxy to ^27.6.0 2026-04-13 18:51:41 +00:00
jkunz bd959464c7 v13.16.1
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 18:08:36 +00:00
jkunz 36b629676f fix(migrations): use exact smartdata collection names in route unification migration 2026-04-13 18:08:36 +00:00
jkunz 19398ea836 v13.16.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 17:38:23 +00:00
jkunz 4aba8cc353 feat(routes): unify route storage and management across config, email, dns, and API origins 2026-04-13 17:38:23 +00:00
jkunz 5fd036eeb6 v13.15.1
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 12:15:11 +00:00
jkunz cfcb66f1ee fix(monitoring): improve domain activity aggregation for multi-domain and wildcard routes 2026-04-13 12:15:11 +00:00
jkunz 501f4f9de6 v13.15.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 12:07:13 +00:00
jkunz fa926eb10b feat(stats): add typed network stats response fields for bandwidth, domain activity, and protocol distribution 2026-04-13 12:07:13 +00:00
jkunz f2d0a9ec1b v13.14.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 11:04:15 +00:00
jkunz 035173702d feat(network): add bandwidth-ranked IP and domain activity metrics to network monitoring 2026-04-13 11:04:15 +00:00
jkunz 07a3365496 v13.13.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 09:47:19 +00:00
jkunz 1c4f7dbb11 feat(dns): add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling 2026-04-13 09:47:19 +00:00
jkunz 1fdff79dd0 v13.12.0
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 23:46:31 +00:00
jkunz 59b52d08fa feat(email-domains): support creating email domains on optional subdomains 2026-04-12 23:46:31 +00:00
jkunz 2cdc392a40 v13.11.0
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 22:09:20 +00:00
jkunz 433047bbf1 feat(email-domains): add email domain management with DNS provisioning, validation, and ops dashboard support 2026-04-12 22:09:20 +00:00
jkunz 0b81c95de2 v13.10.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 20:43:57 +00:00
jkunz 196e5dfc1b feat(web-ui): standardize settings views for ACME and email security panels 2026-04-12 20:43:57 +00:00
jkunz 60d095cd78 v13.9.2
Docker (tags) / security (push) Failing after 2m58s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 19:42:07 +00:00
jkunz 2861511d20 fix(web-ui): improve form field descriptions and align certificate settings with tile components 2026-04-12 19:42:07 +00:00
jkunz b582d44502 v13.9.1
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 15:26:12 +00:00
jkunz 36a2ebc94e fix(network-ui): enable flashing table updates for network activity, remote ingress, and VPN views 2026-04-08 15:26:12 +00:00
jkunz ed52a3188d v13.9.0
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 14:54:49 +00:00
jkunz 93cc5c7b06 feat(dns): add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local 2026-04-08 14:54:49 +00:00
jkunz 5689e93665 v13.8.0
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 13:12:20 +00:00
jkunz c224028495 feat(acme): add DB-backed ACME configuration management and OpsServer certificate settings UI 2026-04-08 13:12:20 +00:00
jkunz 4fbe01823b v13.7.1
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 12:06:08 +00:00
jkunz 34ba2c9f02 fix(repo): no changes to commit 2026-04-08 12:06:08 +00:00
jkunz 52aed0e96e v13.7.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 11:11:53 +00:00
jkunz ea2e618990 feat(dns-providers): add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows 2026-04-08 11:11:53 +00:00
jkunz 140637a307 v13.6.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 11:08:18 +00:00
jkunz 21c80e173d feat(dns): add db-backed DNS provider, domain, and record management with ops UI support 2026-04-08 11:08:18 +00:00
jkunz e77fe9451e v13.5.0
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 09:01:08 +00:00
jkunz 7971bd249e feat(opsserver-access): add admin user listing to the access dashboard 2026-04-08 09:01:08 +00:00
jkunz 6099563acd v13.4.2
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 08:29:30 +00:00
jkunz bf4c181026 fix(repo): no changes to commit 2026-04-08 08:29:30 +00:00
jkunz d9d12427d3 v13.4.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 08:28:00 +00:00
jkunz 91aa9a7228 fix(repo): no changes to commit 2026-04-08 08:28:00 +00:00
jkunz 877356b247 v13.4.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 08:24:55 +00:00
jkunz 2325f01cde feat(web-ui): reorganize dashboard views into grouped navigation with new email, access, and network subviews 2026-04-08 08:24:55 +00:00
jkunz 00fdadb088 v13.3.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:45:26 +00:00
jkunz 2b76e05a40 feat(web-ui): reorganize network and security views into tabbed subviews with route-aware navigation 2026-04-08 07:45:26 +00:00
jkunz 1b37944aab v13.2.2
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:13:01 +00:00
jkunz 35a01a6981 fix(project): no changes to commit 2026-04-08 07:13:01 +00:00
jkunz 3058706d2a v13.2.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:12:16 +00:00
jkunz 0e4d6a3c0c fix(project): no changes to commit 2026-04-08 07:12:16 +00:00
jkunz 2bc2475878 v13.2.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:11:21 +00:00
jkunz 37eab7c7b1 feat(ops-ui): add column filters to operations tables across admin views 2026-04-08 07:11:21 +00:00
jkunz 8ab7343606 v13.1.3
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 00:56:02 +00:00
jkunz f04feec273 fix(certificate-handler): preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains 2026-04-08 00:56:02 +00:00
jkunz d320590ce2 v13.1.2
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-07 22:46:22 +00:00
jkunz 0ee57f433b fix(deps): bump @serve.zone/catalog to ^2.12.3 2026-04-07 22:46:22 +00:00
jkunz b28b5eea84 v13.1.1
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-07 22:28:22 +00:00
jkunz 27d7489af9 fix(deps): bump catalog-related dependencies to newer patch and minor releases 2026-04-07 22:28:22 +00:00
jkunz 940c7dc92e v13.1.0
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-07 21:02:37 +00:00
jkunz 7fa6d82e58 feat(vpn,target-profiles,migrations): add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips 2026-04-07 21:02:37 +00:00
jkunz f29ed9757e fix(target-profile-manager): enhance domain matching to support bidirectional checks 2026-04-06 11:56:55 +00:00
jkunz ad45d1b8b9 v13.0.11
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-06 10:23:19 +00:00
jkunz 68473f8550 fix(routing): serialize route updates and correct VPN-gated route application 2026-04-06 10:23:18 +00:00
jkunz 07cfe76cac v13.0.10
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-06 08:08:23 +00:00
jkunz 3775957bf2 fix(repo): no changes to commit 2026-04-06 08:08:23 +00:00
jkunz 31ce18a025 v13.0.9
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-06 08:07:25 +00:00
jkunz 0cccec5526 fix(repo): no changes to commit 2026-04-06 08:07:25 +00:00
226 changed files with 34589 additions and 9895 deletions
+8
View File
@@ -1,7 +1,15 @@
node_modules/
.nogit/
.git/
.cache/
.rpt2_cache
.yarn/
.playwright-mcp/
.vscode/
coverage/
dist/
dist_*/
pages/
public/
test/
test_watch/
+10 -46
View File
@@ -1,4 +1,4 @@
name: Docker (tags)
name: Docker (non-tag pushes)
on:
push:
@@ -8,42 +8,10 @@ on:
env:
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
security:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Install pnpm and npmci
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
@@ -54,18 +22,14 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
pnpm install -g @git.zone/tsdocker@latest
pnpm install
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test
run: pnpm test
- name: Test build
run: |
npmci npm prepare
npmci node install stable
npmci npm install
npmci command npm run build
- name: Build image
run: tsdocker build
- name: Test image
run: tsdocker test
+14 -77
View File
@@ -8,73 +8,13 @@ on:
env:
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
security:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci command npm run build
release:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:dbase_dind
image: code.foss.global/host.today/ht-docker-dbase:szci
steps:
- uses: actions/checkout@v3
@@ -82,23 +22,20 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @git.zone/tsdocker
pnpm install -g @git.zone/tsdocker@latest
pnpm install
- name: Release
run: |
tsdocker login
tsdocker build
tsdocker push
- name: Login to registries
run: tsdocker login
metadata:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
- name: List images
run: tsdocker list
steps:
- uses: actions/checkout@v3
- name: Build images
run: tsdocker build
- name: Trigger
run: npmci trigger
- name: Test images
run: tsdocker test
- name: Push to code.foss.global
run: tsdocker push code.foss.global
+140
View File
@@ -0,0 +1,140 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Configure pnpm registry
run: pnpm config set registry https://verdaccio.lossless.digital/
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Verify package.json version matches tag
run: |
PACKAGE_VERSION=$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")
TAG_VERSION="${{ steps.version.outputs.version_number }}"
echo "package.json version: $PACKAGE_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "ERROR: Version mismatch!"
exit 1
fi
- name: Test package
run: pnpm test
- name: Build binary artifacts
run: pnpm run build:binary
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
cd ../..
- name: Pack npm artifact
run: |
mkdir -p dist/package
pnpm pack --pack-destination dist/package
ls -lh dist/package
- name: Extract changelog for this version
run: |
VERSION="${{ steps.version.outputs.version }}"
if [ -f changelog.md ]; then
awk "/## $VERSION/,/## /" changelog.md | sed '$d' > /tmp/release_notes.md || true
fi
if [ ! -s /tmp/release_notes.md ]; then
cat > /tmp/release_notes.md << EOF
## DcRouter $VERSION
NodeNext package build plus self-extracting Linux binaries.
### Artifacts
- npm package tarball
- dcrouter-linux-x64
- dcrouter-linux-arm64
- SHA256SUMS.txt
EOF
fi
- name: Delete existing release if it exists
run: |
VERSION="${{ steps.version.outputs.version }}"
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/tags/$VERSION" \
| jq -r '.id // empty')
if [ -n "$EXISTING_RELEASE_ID" ]; then
curl -X DELETE -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$EXISTING_RELEASE_ID"
sleep 2
fi
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases" \
-d "{
\"tag_name\": \"$VERSION\",
\"name\": \"DcRouter $VERSION\",
\"body\": $(jq -Rs . /tmp/release_notes.md),
\"draft\": false,
\"prerelease\": false
}" | jq -r '.id')
for artifact in dist/package/* dist/binaries/*; do
[ -f "$artifact" ] || continue
filename=$(basename "$artifact")
curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$artifact" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$RELEASE_ID/assets?name=$filename"
done
- name: Release Summary
run: |
echo "Release ${{ steps.version.outputs.version }} complete"
ls -lh dist/package
ls -lh dist/binaries
+56 -20
View File
@@ -23,14 +23,39 @@
"outputMode": "bundle",
"bundler": "esbuild",
"production": true,
"includeFiles": ["./html/**/*.html"]
"includeFiles": [
"./html/**/*.html"
]
}
]
},
"@git.zone/tsdeno": {
"compileTargets": [
{
"name": "dcrouter-linux-x64",
"entryPoint": "binary/dcrouter.ts",
"outDir": "dist/binaries",
"target": "x86_64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true,
"selfExtracting": true
},
{
"name": "dcrouter-linux-arm64",
"entryPoint": "binary/dcrouter.ts",
"outDir": "dist/binaries",
"target": "aarch64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true,
"selfExtracting": true
}
]
},
"@git.zone/cli": {
"schemaVersion": 2,
"projectType": "service",
"module": {
"githost": "gitlab.com",
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "dcrouter",
"description": "A traffic router intended to be gating your datacenter.",
@@ -60,26 +85,37 @@
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
"targets": {
"git": {
"enabled": true,
"remote": "origin"
},
"npm": {
"enabled": true,
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"docker": {
"enabled": true,
"engine": "tsdocker"
}
}
}
},
"@ship.zone/szci": {
"npmGlobalTools": [],
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
},
"npmRegistryUrl": "verdaccio.lossless.digital"
},
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registries": [
"code.foss.global"
],
"registryRepoMap": {
"code.foss.global": "serve.zone/dcrouter",
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
"code.foss.global": "serve.zone/dcrouter"
},
"platforms": ["linux/amd64", "linux/arm64"]
}
}
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"@ship.zone/szci": {}
}
+17
View File
@@ -0,0 +1,17 @@
# Agent Instructions for dcrouter
## Database & Migrations
### Collection Names
smartdata uses the **exact class name** as the MongoDB collection name. No lowercasing.
- `StoredRouteDoc` → collection `StoredRouteDoc`
- `TargetProfileDoc` → collection `TargetProfileDoc`
- `RouteDoc` → collection `RouteDoc`
When writing migrations in `ts_migrations/index.ts`, use the exact class name casing in `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
### Migration Rules
- All DB schema migrations go EXCLUSIVELY in `ts_migrations/index.ts` as smartmigration steps.
- NEVER put migration logic in application code (services, managers, startup hooks).
- Migration step `.to()` version must match the release version so smartmigration can plan the step.
- Steps must be idempotent — smartmigration may re-run them in skip-forward resume mode.
+10 -6
View File
@@ -1,12 +1,18 @@
# gitzone dockerfile_service
## STAGE 1 // BUILD
FROM code.foss.global/host.today/ht-docker-node:lts AS build
COPY ./ /app
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm config set registry https://verdaccio.lossless.digital/
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules && pnpm install
RUN pnpm install --frozen-lockfile
COPY . ./
RUN pnpm run build
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
RUN rm -rf .pnpm-store
RUN pnpm prune --prod
## STAGE 2 // PRODUCTION
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
@@ -18,12 +24,10 @@ WORKDIR /app
COPY --from=build /app /app
ENV DCROUTER_MODE=OCI_CONTAINER
ENV NODE_ENV=production
ENV DCROUTER_HEAP_SIZE=512
ENV UV_THREADPOOL_SIZE=16
RUN pnpm install -g @servezone/healthy
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
LABEL org.opencontainers.image.title="dcrouter" \
org.opencontainers.image.description="Multi-service datacenter gateway" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
+4
View File
@@ -0,0 +1,4 @@
process.env.CLI_CALL = 'true';
const cliTool = await import('../dist_ts/index.js');
await cliTool.runCli();
+840 -1
View File
@@ -1,5 +1,844 @@
# Changelog
## 2026-06-05 - 14.1.0
### Features
- add shared WorkApp mail address binding APIs (workapp-mail)
- Adds list, sync, and delete support for shared mail address bindings.
- Maps shared mail address bindings to stored WorkApp mail identities and grouped WorkApp mail bindings.
- Enforces gateway client ownership and allowed mail forward targets for gateway-scoped tokens.
- Updates interface dependencies for shared mail binding request types.
## 2026-06-05 - 14.0.1
### Fixes
- apply inbound PROXY protocol policies per listener (proxy-protocol)
- Apply inbound PROXY protocol policies across prepared and runtime routes that share the same listener.
- Require PROXY protocol for remote ingress SMTP and submission ports while using optional mode for other remote ingress and VPN listeners.
- Trust localhost for remote ingress and VPN forwarding without globally enabling PROXY protocol.
- Bump @push.rocks/smartproxy to ^27.12.8.
## 2026-06-04 - 14.0.0
### Breaking Changes
- remove legacy config seeding and route-based certificate reprovisioning (config)
- Make ACME configuration DB-backed only and report DB-backed ACME state in the OpsServer config response.
- Stop seeding DNS domains and records from constructor config at runtime.
- Remove the route-name certificate reprovision typed request; domain-based reprovisioning remains available.
- Remove legacy string email-domain normalization from runtime email startup.
### Fixes
- bump @push.rocks/smartproxy to ^27.12.7 (deps)
- Consumes the upstream SmartProxy socket-handler relay fix for server-first SMTP banners.
- Updates the lockfile to resolve @push.rocks/smartproxy 27.12.7.
- use exact SmartData collection names in DNS migrations (migrations)
- Updates DNS source rename migrations to use `DomainDoc` and `DnsRecordDoc` collection names.
- Adds migration coverage for exact SmartData collection names.
## 2026-06-04 - 13.45.0
### Fixes
- relay server-first SMTP banners for generated email routes (email)
- Convert generated plaintext email forward routes to runtime socket handlers for SmartProxy bootstrap.
- Hydrate DB-backed generated email routes to the same runtime handlers when their email system keys match.
- Add bidirectional socket proxy cleanup and tests for route hydration and SMTP banner relay.
### Features
- add route source policy editor (network-routes)
- Replace fixed source binding dropdown rows with the catalog route source policy input in route create and edit dialogs.
- Add source profile normalization, path class options, Gitea source policy presets, and validation for route source policies.
- Bump catalog UI dependencies and update pnpm built dependency configuration.
## 2026-06-04 - 13.44.1
### Fixes
- use smartdata cached document support (db)
- Migrate cached email and IP reputation documents to SmartdataCachedDocument and shared smartdata TTL values.
- Remove the local cached document base class and TTL export.
- Bump @push.rocks/smartdata to ^7.2.0.
## 2026-06-04 - 13.44.0
### Features
- add DB-backed email and RemoteIngress hub settings (settings)
- Add persisted email server settings with ops API handlers and web UI controls.
- Extend RemoteIngress hub settings to manage enabled state, tunnel port, hub domain, and performance from the database.
- Backfill email and RemoteIngress singleton settings from legacy bootstrap configuration during migrations.
- Serialize SmartProxy, RemoteIngress, and email lifecycle updates to avoid overlapping runtime reconfiguration.
## 2026-06-03 - 13.43.5
### Fixes
- bump @serve.zone/catalog to ^2.12.8 (deps)
- Updated @serve.zone/catalog from ^2.12.7 to ^2.12.8.
## 2026-06-03 - 13.43.4
### Fixes
- track tunnel streams using summary events (remoteingress)
- Enable summary stream event mode for the RemoteIngress hub.
- Synchronize active tunnel counts and stream totals from stream summary events.
- Bump @serve.zone/remoteingress to ^4.23.0.
- Remove obsolete Deno import map entries.
## 2026-06-03 - 13.43.3
### Fixes
- bump @push.rocks/smartproxy to ^27.12.6 (deps)
- Updates package and Deno import dependencies from @push.rocks/smartproxy ^27.12.4 to ^27.12.6.
## 2026-06-03 - 13.43.2
### Fixes
- enforce canonical source bindings for route access (route-management)
- Convert route access metadata to ordered `metadata.sourceBindings[]` and remove active runtime use of legacy source policy/source profile fields.
- Fail closed for managed gateway/workhoster routes without source bindings and add terminal deny fallbacks for private-only bindings.
- Add migration coverage, Ops route UI updates, and documentation for the canonical source binding model.
## 2026-06-03 - 13.43.1
### Fixes
- ignore generated artifacts and caches in Docker build context (dockerignore)
- Exclude cache directories, coverage reports, distribution outputs, and generated static assets from Docker contexts.
## 2026-06-03 - 13.43.0
### Features
- add derived HTTP-to-HTTPS redirects (http-redirects)
- Generate 301 runtime redirect routes from eligible HTTPS routes while detecting existing HTTP route coverage or conflicts
- Expose derived redirect metadata through the getHttpRedirects typed request API
- Add an Ops Redirects network view with redirect status metrics and table details
- Add tests for redirect derivation, conflict handling, and preserving request host/path
## 2026-06-02 - 13.42.4
### Fixes
- normalize source policy route priorities to stable integers (source-policy-compiler)
- Assign integer priorities to compiled source policy route variants while preserving relative priority order.
- Keep path-specific source policy variants ranked above fallback variants.
- update Deno import dependencies (deps)
- Bumped Deno import map versions for API, identity, push.rocks, serve.zone, and lru-cache dependencies.
## 2026-06-02 - 13.42.3
### Fixes
- update dependency versions (deps)
- Bumped runtime dependencies including @serve.zone/interfaces to ^6.2.1, @serve.zone/catalog to ^2.12.7, and lru-cache to ^11.5.1.
- Updated @git.zone/tsdocker dev dependency to ^2.4.2.
## 2026-06-02 - 13.42.2
### Fixes
- bump @git.zone/tsdocker to ^2.4.1 (dev-deps)
- Updated @git.zone/tsdocker from ^2.4.0 to ^2.4.1.
## 2026-06-02 - 13.42.1
### Fixes
- bump @serve.zone/remoteingress to ^4.22.5 (deps)
- Updates @serve.zone/remoteingress from ^4.22.4 to ^4.22.5.
## 2026-06-02 - 13.42.0
### Features
- add ordered route source policies with Gitea preset support (source-policy)
- Compile metadata.sourcePolicy bindings into SmartProxy route variants with ordered source matching, path-class overrides, and terminal 429 rate/connection limit handling
- Add shared source-policy interfaces, Gitea path-class patterns, validation limits, and resolver support for policy-backed profile usage and display names
- Add Ops UI controls for manual and Gitea source-policy presets plus rate-limit editing for source profiles
- Seed TRUSTED NETWORKS, AI CRAWLERS, and PUBLIC default profiles through defaults and the 13.42.0 migration
- Bump smartproxy to ^27.12.4 and add coverage for source-policy compilation, rate-limit behavior, migrations, and port-safe server tests
## 2026-06-01 - 13.41.2
### Fixes
- update SmartProxy and RemoteIngress dependencies (deps)
- Bump SmartProxy to 27.12.3 for the published half-close regression coverage.
- Bump RemoteIngress to 4.22.4 for the half-close/reset and UDP startup lifecycle fixes.
- Align npm and Deno import metadata for both runtime dependencies.
## 2026-05-31 - 13.41.1
### Fixes
- prevent SmartAcme startup from blocking router startup (smartacme)
- Start SmartAcme in the background with bounded exponential retry handling
- Re-trigger certificate provisioning after SmartAcme becomes ready
- Cancel stale retry timers and clean up SmartAcme instances during shutdown or config updates
## 2026-05-31 - 13.41.0
### Features
- add RemoteIngress hub settings management (remoteingress)
- Persist hub-level RemoteIngress performance settings with validation and seed defaults from config
- Add typed read/update handlers and web UI controls for hub performance settings
- Restart the tunnel hub after hub setting updates so new performance defaults take effect
- Serialize RemoteIngress lifecycle tasks, edge mutations, route syncs, and stop/start operations to avoid hub race conditions
## 2026-05-31 - 13.40.3
### Fixes
- bump smartproxy and remoteingress dependencies (deps)
- Bumped @push.rocks/smartproxy from ^27.12.1 to ^27.12.2
- Bumped @serve.zone/remoteingress from ^4.22.2 to ^4.22.3
- Updated dependency versions in both package.json and deno.json
## 2026-05-31 - 13.40.2
### Fixes
- ensure source profiles fully own route security (routes)
- Resolve profile-backed routes by cloning source profile security instead of merging inline route overrides
- Clear stale route security when a source profile reference is removed without explicit replacement security
- Add a migration to rematerialize persisted profile-backed route security
## 2026-05-31 - 13.40.1
### Fixes
- update smartproxy, remoteingress, and tsdeno dependencies (deps)
- Bump @push.rocks/smartproxy to ^27.12.1 in Deno imports
- Bump @serve.zone/remoteingress to ^4.22.2 in package and Deno configuration
- Bump @git.zone/tsdeno to ^1.5.0
## 2026-05-30 - 13.40.0
### Features
- use active connection snapshots for proxy metrics and RADIUS network secrets (monitoring-opsserver-radius)
- Add cached SmartProxy active connection snapshots for connection info and network statistics.
- Report ops security active connections from per-connection snapshots with protocol, state, and byte counters.
- Configure RADIUS clients through smartradius network secrets, including CIDR ranges, and forward additional RADIUS attributes.
- Bump smartproxy to ^27.12.1 and smartradius to ^1.3.0.
## 2026-05-30 - 13.39.0
### Features
- add remote ingress performance overrides and update RADIUS integration (remoteingress,radius)
- Persist and propagate optional remote ingress performance overrides through remote ingress create/update APIs, database documents, and hub allowed-edge sync.
- Add web UI controls and status display for per-edge maximum connection overrides.
- Extend remote ingress performance interfaces with stream payload, timeout, and server-first port settings.
- Update RADIUS server integration for smartradius 1.2 request/response handling and client secret resolution, including CIDR matching.
## 2026-05-30 - 13.38.4
### Fixes
- bump @serve.zone/remoteingress to ^4.22.1 (deps)
- Updated @serve.zone/remoteingress in package.json and deno.json.
## 2026-05-30 - 13.38.3
### Fixes
- update @serve.zone/remoteingress to ^4.22.0 (deps)
- Updated @serve.zone/remoteingress from ^4.21.1 to ^4.22.0 in package.json and deno.json.
## 2026-05-30 - 13.38.2
### Fixes
- bump @serve.zone/remoteingress to ^4.21.1 (deps)
- Updated @serve.zone/remoteingress in package.json and deno.json from ^4.21.0 to ^4.21.1.
## 2026-05-30 - 13.38.1
### Fixes
- bump @serve.zone/remoteingress to ^4.21.0 (deps)
- Updates @serve.zone/remoteingress from ^4.18.0 to ^4.21.0.
- update @serve.zone/remoteingress to ^4.21.0 (deps)
- Updates the Deno import mapping for @serve.zone/remoteingress from ^4.18.0 to ^4.21.0.
## 2026-05-29 - 13.38.0
### Features
- support explicit DNS bind interface configuration (dns)
- Add a dnsBindInterface option to override the embedded DNS UDP bind address.
- Read DCROUTER_DNS_BIND_INTERFACE from OCI container configuration and document it in CLI help.
- Add test coverage for explicit DNS bind interface handling in OCI config.
## 2026-05-29 - 13.37.2
### Fixes
- exclude assets from compiled and published artifacts (packaging)
- Removed assets from the Deno compile include list.
- Removed assets from the npm package files list.
## 2026-05-29 - 13.37.1
### Fixes
- configure pnpm registry for release workflow (release)
- Sets the pnpm registry before dependency installation so release builds resolve packages from the configured registry.
## 2026-05-29 - 13.37.0
### Features
- add CLI binary distribution (distribution)
- Add dcrouter bin entry, Deno compile targets, binary entrypoint, and tag-driven release workflow for Linux artifacts.
- Add --version and --help handling to the CLI for safe package and binary smoke tests.
- Keep the Deno binary import map aligned with the current SmartDNS and SmartProxy runtime dependencies.
- add one-line installer and Docker distribution docs (distribution)
- Add an install.sh flow that installs Linux x64 and arm64 release binaries by default with a NodeNext source-build fallback.
- Document installer modes, binary artifact names, and the published multi-arch Docker image.
## 2026-05-29 - 13.36.3
### Fixes
- update SmartProxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts
- Bump @push.rocks/smartproxy to ^27.11.1.
- Prevent public gateway WebSocket routes from inheriting the HTTP socket timeout.
- bump smartproxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts (deps)
- Bump @push.rocks/smartproxy to ^27.11.1.
- Prevent public gateway WebSocket routes from inheriting the HTTP socket timeout.
## 2026-05-29 - 13.36.2
### Fixes
- preserve parallel ACME DNS-01 TXT challenges and consume case-insensitive DNS matching (dns,certificates)
- Keep exact and wildcard SAN challenge TXT records at the same owner name instead of deleting sibling challenge values.
- Match local dcrouter-hosted DNS records case-insensitively so DNS 0x20 mixed-case queries keep resolving.
- Update @push.rocks/smartdns to 7.9.3 for case-insensitive handler matching in the embedded DNS server.
- preserve parallel ACME TXT challenges and mixed-case DNS queries (dns)
- Remove only matching ACME DNS-01 TXT challenge values during setup and cleanup so parallel challenges can coexist.
- Resolve locally hosted DNS records case-insensitively while preserving the query name casing in responses.
- Bump @push.rocks/smartdns to ^7.9.3.
## 2026-05-28 - 13.36.1
### Fixes
- consume RemoteIngress 4.18.0 tunnel performance improvements (remoteingress)
- Update @serve.zone/remoteingress to 4.18.0 so DcRouter uses zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix.
- bump @serve.zone/remoteingress to ^4.18.0 (remoteingress)
- Updates @serve.zone/remoteingress from ^4.17.1 to ^4.18.0.
- Consumes zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix from RemoteIngress.
## 2026-05-28 - 13.36.0
### Features
- add top connected ASN activity to Network Activity (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose ASN activity through network stats and combined metrics APIs.
- Add a Network Activity table with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
- add top connected ASN activity to network monitoring (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose top ASN activity through network stats and combined metrics API responses.
- Add a Network Activity table for top ASNs with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
## 2026-05-24 - 13.35.0
### Features
- switch VPN route authorization to authenticated SmartVPN metadata (vpn)
- configure SmartVPN to forward real client source IPs plus VPN metadata through trusted PROXY v2 headers
- map target profiles to SmartProxy VPN client grants instead of mutating route source IP allow lists
- keep live VPN client source IP tracking as status/UI data while SmartProxy enforces source policy per connection
## 2026-05-21 - 13.34.0
### Features
- allow VPN target profiles to grant routes by live client source IP (vpn)
- Add an opt-in target profile flag that evaluates non-vpnOnly route source security against the VPN client's real connecting IP.
- Track live VPN client source IPs from smartvpn remote addresses and WireGuard peer endpoints, refreshing routes when they change.
- Expose the setting and current source IPs in the Ops UI with regression coverage for source-IP matching behavior.
- allow target profiles to grant non-vpnOnly routes by live client source IP (vpn)
- add an opt-in target profile flag to match route source security against a VPN client's real connecting IP
- track live client source IPs from VPN remote addresses and WireGuard peer endpoints and re-apply routes when they change
- expose source IP access settings and current client source IPs through the ops API and UI
- add regression tests for source-IP route matching, block-list handling, vpnOnly exclusions, and WireGuard endpoint refresh
## 2026-05-21 - 13.33.0
### Features
- add queued IP intelligence observation and filtered retrieval for network and security views (security)
- Queue observed public IPs from network metrics with throttled background enrichment instead of awaiting lookups during stats collection.
- Allow listing IP intelligence records by specific IP addresses and limit through the security handler and request interface.
- Update web app state to refresh IP intelligence asynchronously in the background and preserve current UI state during refreshes.
- Improve security policy manager observation handling so forced refresh waits for in-flight lookups before fetching updated intelligence.
## 2026-05-20 - 13.32.1
### Fixes
- tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules (opsserver,vpn)
- Block ephemeral admin bootstrap login and user listing until the configured database is ready, and report bootstrap availability accurately in admin status responses.
- Preserve persisted admin accounts across OpsServer restarts with added regression coverage.
- Merge matching VPN client IPs into restricted non-vpnOnly route allow lists without duplicating entries.
- Handle string and wildcard route domains consistently when resolving target profile access and VPN client matches.
## 2026-05-19 - 13.32.0
### Features
- add scoped API token auth across ops endpoints (ops-auth)
- introduces a shared requireOpsAuth helper that validates JWT identities and API tokens with scope and admin-policy checks
- applies explicit per-endpoint authorization across config, logs, stats, security, VPN, RADIUS, remote ingress, users, API tokens, and related ops handlers
- extends request interfaces and UI scope definitions to support apiToken-based access and adds tests for auth behavior and migration bridging
## 2026-05-19 - 13.31.0
### Features
- add admin user create/delete management and default hosted idp.global auth support (opsserver)
- adds admin-only createUser and deleteUser typed requests with safeguards against deleting the current user or last active admin
- updates the ops users UI to create and delete users, show richer account details, and support optional idp.global login during account creation
- treats idp.global as available by default via the hosted https://idp.global endpoint while keeping URL settings as optional overrides
- adds VPN-only route controls and indicators in the ops routes UI
## 2026-05-18 - 13.30.0
### Features
- document first-admin bootstrap flow and update authentication examples (docs)
- Add README guidance for explicit initial admin creation on DB-backed instances across the main package, API client, interfaces, and web dashboard docs.
- Update authentication examples to use persisted admin email/password credentials instead of the old default admin login.
- Refresh dependency versions in package.json to align documentation with current package releases.
## 2026-05-14 - 13.29.1
### Fixes
- enable npm publishing in smartconfig (smartconfig)
- Sets the npm integration flag to true in .smartconfig.json
- Keeps the configured Verdaccio and npmjs registries unchanged
## 2026-05-14 - 13.29.0
### Fixes
- harden VPN route access and wireguard client configuration handling (vpn)
- Fail closed for vpnOnly routes when no VPN client IPs are available by replacing allow lists and enforcing a block-all fallback
- Refresh route application and VPN client security after target profile creation so profile changes take effect immediately
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
### Features
- add persisted admin bootstrap flow with optional idp.global authentication (opsserver-admin)
- introduces bootstrap status and initial admin creation endpoints for OpsServer
- switches admin authentication from ephemeral-only users to database-backed accounts when a persistent admin exists
- adds optional idp.global login support for admin accounts and exposes auth source metadata in user listings
- updates the web dashboard to prompt creation of the first persisted admin account
- adds integration coverage for bootstrap, persisted login, identity invalidation, and user listing behavior
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
add managed gateway client administration and token-bound route ownership
- introduce persistent gateway client management with create, update, delete, list, and scoped token creation flows
- add gateway client context and ownership resolution so token-bound clients can sync routes without spoofing another client
- surface gateway client administration in the ops dashboard with a new Access > Gateway Clients view
- mark certificate provisioning backoff failures as failed and expose root-cause errors with DNS management guidance in the certificates view
## 2026-05-09 - 13.27.1 - fix(docker)
configure pnpm to use the verdaccio registry during Docker builds
- Adds a pnpm registry configuration step before dependency installation in the Dockerfile.
- Ensures container builds resolve packages from the configured Verdaccio registry.
## 2026-05-09 - 13.27.0 - feat(api-token-manager)
seed and rotate the environment-managed admin API token during initialization
- Add initialization support for DCROUTER_ADMIN_API_TOKEN with validation, persistence, and admin policy assignment
- Ensure the environment-managed token is updated when the configured raw token changes
- Refactor token hashing into a shared helper and add coverage for seeding, validation, redaction, and rotation behavior
## 2026-05-09 - 13.26.0 - feat(gateway-clients)
add policy-based gateway client tokens and gateway client route and DNS management endpoints
- Introduces API token policies with admin and gatewayClient roles, capability checks, hostname restrictions, and allowed route targets.
- Adds gateway client request and data interfaces for domains, DNS records, route sync, and ownership metadata while keeping workhoster aliases for compatibility.
- Extends route metadata normalization to prefer gatewayClient ownership and updates generated route names and test coverage accordingly.
## 2026-04-26 - 13.25.0 - feat(security)
compile network ranges and CIDR arrays into edge firewall policies
- add support for storing intelligence network CIDR arrays alongside single network ranges
- convert start-end IPv4 ranges into CIDR blocks when compiling security policies
- always return an explicit remote ingress firewall snapshot with a blockedIps array
- add tests covering range normalization, ASN-derived CIDRs, and empty firewall snapshots
## 2026-04-26 - 13.24.0 - feat(security)
add security policy management and IP intelligence operations to the ops UI
- adds typed request endpoints to fetch compiled security policy, list audit events, and force-refresh IP intelligence
- introduces dedicated security policy state and actions for loading, creating, updating, deleting, and refreshing security data
- enhances the network activity view with IP intelligence columns, detail dialogs, and block-rule actions
- expands the security blocked view into a full management interface for rules, compiled policy, IP intelligence, and audit history
## 2026-04-26 - 13.23.0 - feat(security)
add managed security policies with IP intelligence and remote ingress firewall propagation
- introduces a SecurityPolicyManager that observes public IPs, stores IP intelligence, compiles block policies, and audits policy changes
- adds database documents and shared interfaces for security block rules, IP intelligence records, and security policy audit events
- exposes ops/admin request handlers to list IP intelligence and create, update, or delete security block rules
- applies merged security policies to SmartProxy and propagates firewall snapshots to remote ingress edges and tunnel synchronization
## 2026-04-26 - 13.22.0 - feat(remoteingress)
add remote ingress performance configuration and expose tunnel transport metrics
- upgrade @serve.zone/remoteingress to support performance tuning and richer tunnel status data
- pass remote ingress performance settings through router startup and config APIs
- serialize allowed-edge sync operations and await route update hooks to avoid tunnel sync races
- expose UDP listen ports and transport, flow control, queue, and traffic metrics in remote ingress APIs and ops UI
## 2026-04-26 - 13.21.1 - fix(deps)
bump @push.rocks/smartproxy to ^27.8.1
- Updates @push.rocks/smartproxy from ^27.8.0 to ^27.8.1 in package.json.
## 2026-04-25 - 13.21.0 - feat(monitoring)
improve network activity metrics with live domain request rates and backend identifiers
- use SmartProxy per-domain live request rates to rank and attribute domain activity metrics, while retaining lifetime request totals as fallback data
- separate aggregate backend rows from protocol cache rows with stable ids so cached protocol entries no longer duplicate active backend connection counts
- expose frontend and backend protocol distributions plus aggregated connectionCount fields through ops and web network views
## 2026-04-17 - 13.20.2 - fix(vpn)
handle VPN forwarding mode downgrades and support runtime VPN config updates
- restart the VPN server back to socket mode when host-IP clients are removed while preserving explicit hybrid mode
- allow DcRouter to update VPN configuration at runtime and refresh route allow-list resolution without recreating the router
- improve VPN operations UI target profile rendering and loading behavior for create and edit flows
## 2026-04-17 - 13.20.1 - fix(docs)
refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance
- Reworks the main README with updated positioning, quick-start examples, route ownership guidance, configuration notes, automation examples, and OCI bootstrap details
- Expands package-specific readmes for the runtime, API client, interfaces, migrations, and web dashboard to better describe exports, behavior, and usage
- Standardizes documentation references such as subpath import guidance and LICENSE link casing across readmes
## 2026-04-17 - 13.20.0 - feat(routes)
add remote ingress controls and preserve-port targeting for route configuration
- Allow route updates to remove optional top-level properties by treating null values like remoteIngress as explicit clears.
- Add route form support for preserving the matched incoming port when forwarding to backend targets.
- Add remote ingress enablement and edge filter controls to route create/edit views.
- Cover remoteIngress removal behavior with a runtime route manager test.
## 2026-04-16 - 13.19.1 - fix(routes)
preserve inline target ports when clearing network target references
- Normalize route metadata so empty reference fields are removed instead of persisted.
- Allow the routes UI to clear source profile and network target references explicitly during edits.
- Disable inline target host and port inputs when a network target is selected and validate target ports when using manual targets.
- Add runtime route tests covering removal of a network target reference while keeping the edited inline target port.
## 2026-04-15 - 13.19.0 - feat(routes,email)
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers
- Persist seeded DNS-over-HTTPS routes with stable system keys and hydrate socket handlers at runtime instead of treating them as runtime-only routes
- Restrict system-managed routes to toggle-only operations across the route manager, Ops API, and web UI while returning explicit mutation errors
- Add a shared email DNS record builder and cover email queue operations and handler behavior with new tests
## 2026-04-14 - 13.18.0 - feat(email)
add persistent smartmta storage and runtime-managed email domain syncing
- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence
- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart
- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation
## 2026-04-14 - 13.17.9 - fix(monitoring)
align domain activity metrics with id-keyed route data
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
- Add a regression test covering domain activity aggregation for routes identified only by id.
- Update the network activity UI to show formatted total connection counts in the active connections card.
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
## 2026-04-14 - 13.17.8 - fix(opsserver)
align certificate status handling with the updated smartproxy response format
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
- bump @push.rocks/smartproxy to ^27.7.3
- enable verbose output for the test script
## 2026-04-14 - 13.17.7 - fix(repo)
no changes to commit
## 2026-04-14 - 13.17.6 - fix(dns,routes)
keep DoH socket-handler routes runtime-only and prune stale persisted entries
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
normalize target profile route references and stabilize VPN host-IP client routing behavior
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
sync route filter toggle selection via component changeSubject
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
## 2026-04-13 - 13.17.2 - fix(monitoring)
exclude unconfigured routes from domain activity aggregation
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
## 2026-04-13 - 13.17.1 - fix(monitoring)
stop allocating route metrics to domains when no request data exists
- Removes the equal-split fallback for shared routes in MetricsManager.
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
add request-based domain activity metrics and split routes into user and system views
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
## 2026-04-13 - 13.16.2 - fix(deps)
bump @push.rocks/smartproxy to ^27.6.0
- updates @push.rocks/smartproxy from ^27.5.0 to ^27.6.0 in package.json
## 2026-04-13 - 13.16.1 - fix(migrations)
use exact smartdata collection names in route unification migration
- Update the 13.16.0 migration to rename StoredRouteDoc to RouteDoc using case-sensitive collection names
- Apply the origin backfill against the RouteDoc collection and drop RouteOverrideDoc with matching class-name casing
- Clarify migration description and comments to reflect smartdata's exact class-name collection mapping
## 2026-04-13 - 13.16.0 - feat(routes)
unify route storage and management across config, email, dns, and API origins
- Persist config-, email-, and dns-seeded routes in the database alongside API-created routes using a single RouteDoc model with origin tracking
- Remove hardcoded-route override handling in favor of direct route CRUD and toggle operations by route id across the API client, handlers, and web UI
- Add a migration that renames stored route storage, sets migrated routes to origin="api", and drops obsolete route override data
## 2026-04-13 - 13.15.1 - fix(monitoring)
improve domain activity aggregation for multi-domain and wildcard routes
- map route metrics across all configured domains instead of only the first domain
- resolve wildcard domain patterns against active protocol cache entries
- distribute shared route traffic across matched domains and preserve fallback reporting for routes without domain configuration
## 2026-04-13 - 13.15.0 - feat(stats)
add typed network stats response fields for bandwidth, domain activity, and protocol distribution
- extends the network stats request interface with top IP bandwidth, domain activity, and frontend/backend protocol distribution data
- updates app state to use a typed getNetworkStats request instead of casting the response to any
## 2026-04-13 - 13.14.0 - feat(network)
add bandwidth-ranked IP and domain activity metrics to network monitoring
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
## 2026-04-13 - 13.13.0 - feat(dns)
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
- adds domain migration support in DnsManager, API handlers, request interfaces, app state, and domains UI
- routes ACME DNS-01 challenges through managed domains using createRecord/deleteRecord for both dcrouter-hosted and provider-managed zones
- enables immediate unregister of deleted dcrouter-hosted DNS records from the embedded DNS server
## 2026-04-12 - 13.12.0 - feat(email-domains)
support creating email domains on optional subdomains
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
## 2026-04-12 - 13.11.0 - feat(email-domains)
add email domain management with DNS provisioning, validation, and ops dashboard support
- Introduce EmailDomainManager with persisted email domain records, DKIM configuration, DNS record generation, provisioning, and validation.
- Add opsserver typed request handlers and shared interfaces for listing, creating, updating, deleting, validating, and provisioning email domains.
- Add ops dashboard email domains view and app state integration for managing domains and inspecting required DNS records.
## 2026-04-12 - 13.10.0 - feat(web-ui)
standardize settings views for ACME and email security panels
- replace custom ACME settings layouts with the reusable dees-settings component for configured and empty states
- update the email security view to present settings through dees-settings and open a modal-based read-only edit dialog
- bump @design.estate/dees-catalog to ^3.78.0 to support the updated UI components
## 2026-04-12 - 13.9.2 - fix(web-ui)
improve form field descriptions and align certificate settings with tile components
- Refines labels and adds descriptive helper text across API token, DNS, domain, route, edge, target profile, and VPN forms for clearer operator input
- Updates the DNS provider form to surface provider and credential guidance through built-in input metadata instead of custom help blocks
- Restyles the certificates ACME settings section to use tile-based layout and improves related form wording and file upload metadata
- Refreshes the Cloudflare DNS provider description and bumps UI-related dependencies
## 2026-04-08 - 13.9.1 - fix(network-ui)
enable flashing table updates for network activity, remote ingress, and VPN views
- adds stable row keys to dees-table instances so existing rows can be diffed correctly
- enables flash highlighting for changed rows and cells across network activity, top IPs, backends, remote ingress edges, and VPN clients
- updates network activity request data on every refresh so live metrics like duration and byte counts visibly refresh
## 2026-04-08 - 13.9.0 - feat(dns)
add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local
- Expose a synthetic built-in "DcRouter" provider in provider listings and block create, edit, delete, test, and external domain listing operations for it
- Rename domain and record source semantics from "manual" to "dcrouter" and "local" across backend, interfaces, and UI
- Add database migrations to convert existing DomainDoc.source and DnsRecordDoc.source values to the new naming
- Update domain creation flows and provider UI labels to reflect dcrouter-hosted authoritative domains
## 2026-04-08 - 13.8.0 - feat(acme)
add DB-backed ACME configuration management and OpsServer certificate settings UI
- introduces a singleton AcmeConfig manager and document persisted in the database, with first-boot seeding from legacy tls.contactEmail and smartProxyConfig.acme options
- updates SmartProxy startup to read live ACME settings from the database and only enable DNS-01 challenge wiring when ACME is configured and enabled
- adds authenticated OpsServer typed request endpoints and API token scopes for reading and updating ACME configuration
- adds web app state and a certificates view card/modal for viewing and editing ACME settings from the Domains certificate UI
## 2026-04-08 - 13.7.1 - fix(repo)
no changes to commit
## 2026-04-08 - 13.7.0 - feat(dns-providers)
add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows
- Introduce shared DNS provider type descriptors and credential field metadata to drive provider forms dynamically.
- Add a reusable dns-provider-form component and update provider create/edit dialogs to use typed provider selection and credential handling.
- Remove Cloudflare-specific ACME helper exposure and clarify provider-agnostic DNS manager and factory documentation for future provider implementations.
## 2026-04-08 - 13.6.0 - feat(dns)
add db-backed DNS provider, domain, and record management with ops UI support
- introduce DnsManager-backed persistence for DNS providers, domains, and records with Cloudflare provider support
- replace constructor-based ACME DNS challenge configuration with provider records stored in the database
- add opsserver typed request handlers and API token scopes for managing DNS providers, domains, and records
- add a new Domains section in the ops UI for providers, domains, DNS records, and certificates
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
add admin user listing to the access dashboard
- register a new admin-only typed request endpoint to list users with id, username, and role while excluding passwords
- add users state management and a dedicated access dashboard view for browsing OpsServer user accounts
- update access routing to include the new users subview and improve related table filtering and section headings
## 2026-04-08 - 13.4.2 - fix(repo)
no changes to commit
## 2026-04-08 - 13.4.1 - fix(repo)
no changes to commit
## 2026-04-08 - 13.4.0 - feat(web-ui)
reorganize dashboard views into grouped navigation with new email, access, and network subviews
- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security.
- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation.
- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior.
- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling.
## 2026-04-08 - 13.3.0 - feat(web-ui)
reorganize network and security views into tabbed subviews with route-aware navigation
- add URL-based subview support in app state and router for network and security sections
- group routes, source profiles, network targets, and target profiles under the network view with tab navigation
- split security into dedicated overview, blocked IPs, authentication, and email security subviews
- update configuration navigation to deep-link directly to the network routes subview
## 2026-04-08 - 13.2.2 - fix(project)
no changes to commit
## 2026-04-08 - 13.2.1 - fix(project)
no changes to commit
## 2026-04-08 - 13.2.0 - feat(ops-ui)
add column filters to operations tables across admin views
- Enable table column filters for API tokens, certificates, network requests, top IPs, backends, network targets, remote ingress edges, security views, source profiles, target profiles, and VPN clients.
- Improves filtering and exploration of operational data throughout the admin interface without changing backend behavior.
## 2026-04-08 - 13.1.3 - fix(certificate-handler)
preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
- add deriveCertDomainName helper to match shared ACME certificate identities across wildcard and subdomain routes
- pass includeWildcard when force-renewing certificates so renewed certs keep wildcard SAN coverage for sibling subdomains
- persist renewed certificate data to all sibling route domains that share the same cert identity and clear cached certificate status entries
- add regression tests for certificate domain derivation and force-renew wildcard handling
## 2026-04-07 - 13.1.2 - fix(deps)
bump @serve.zone/catalog to ^2.12.3
- Updates @serve.zone/catalog from ^2.12.0 to ^2.12.3 in package.json
## 2026-04-07 - 13.1.1 - fix(deps)
bump catalog-related dependencies to newer patch and minor releases
- update @design.estate/dees-catalog from ^3.66.0 to ^3.67.1
- update @serve.zone/catalog from ^2.11.2 to ^2.12.0
## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations)
add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips
- runs smartmigration at startup before configuration is loaded and adds a migration for target profile targets from host to ip
- changes VPN client routing to always force traffic through SmartProxy while allowing direct target bypasses from target profiles
- supports domain-scoped VPN ipAllowList entries for vpnOnly routes based on matching target profile domains
- updates certificate reprovisioning to reapply routes so renewed certificates are loaded into the running proxy
- removes the forceDestinationSmartproxy VPN client option from API, persistence, manager, and web UI
## 2026-04-06 - 13.0.11 - fix(routing)
serialize route updates and correct VPN-gated route application
- RouteConfigManager now serializes concurrent applyRoutes calls to prevent overlapping SmartProxy updates and stale route overwrites.
- VPN-only routes deny access until VPN state is ready, then re-apply routes after VPN clients load or change to refresh ipAllowLists safely.
- Certificate provisioning retries now go through RouteConfigManager when available so the full merged route set is reapplied consistently.
- Reference resolution now expands network targets with multiple hosts into multiple route targets.
- Adds rollback when VPN client persistence fails, enforces unique target profile names, and fixes maxConnections parsing in the source profiles UI.
## 2026-04-06 - 13.0.10 - fix(repo)
no changes to commit
## 2026-04-06 - 13.0.9 - fix(repo)
no changes to commit
## 2026-04-06 - 13.0.8 - fix(ops-view-vpn)
show target profile names in VPN forms and load profile candidates for autocomplete
@@ -2228,4 +3067,4 @@ Applied a core fix.
- Fixed core functionality for version 1.0.1
–––––––––––––––––––––––
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@serve.zone/dcrouter",
"version": "14.1.0",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
"dist_serve"
]
}
}
Executable
+359
View File
@@ -0,0 +1,359 @@
#!/bin/bash
# DcRouter Installer Script
# Installs the self-extracting Linux binary by default, or builds the NodeNext
# source package when --source is specified.
#
# Usage:
# Binary install:
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
#
# Source install:
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
#
# Options:
# -h, --help Show this help message
# --version VERSION Install a specific tag/version (e.g. vX.Y.Z)
# --install-dir DIR Installation directory (default: /opt/dcrouter)
# --binary Install release binary (default)
# --source Clone the tag and build the NodeNext package locally
set -euo pipefail
SHOW_HELP=0
SPECIFIED_VERSION=""
INSTALL_DIR="/opt/dcrouter"
INSTALL_MODE="binary"
GITEA_BASE_URL="https://code.foss.global"
GITEA_REPO="serve.zone/dcrouter"
SERVICE_NAME="dcrouter"
BIN_DIR="/usr/local/bin"
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
SHOW_HELP=1
shift
;;
--version)
if [[ $# -lt 2 ]]; then
echo "Error: --version requires a value"
exit 1
fi
SPECIFIED_VERSION="$2"
shift 2
;;
--install-dir)
if [[ $# -lt 2 ]]; then
echo "Error: --install-dir requires a value"
exit 1
fi
INSTALL_DIR="$2"
shift 2
;;
--binary)
INSTALL_MODE="binary"
shift
;;
--source)
INSTALL_MODE="source"
shift
;;
*)
echo "Unknown option: $1"
echo "Use -h or --help for usage information"
exit 1
;;
esac
done
if [[ $SHOW_HELP -eq 1 ]]; then
echo "DcRouter Installer Script"
echo "Installs DcRouter as a self-extracting binary or NodeNext source build."
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --version VERSION Install a specific tag/version (e.g. vX.Y.Z)"
echo " --install-dir DIR Installation directory (default: /opt/dcrouter)"
echo " --binary Install release binary (default)"
echo " --source Clone the tag and build the NodeNext package locally"
echo ""
echo "Examples:"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --version vX.Y.Z"
exit 0
fi
if [[ "$EUID" -ne 0 ]]; then
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
exit 1
fi
case "$INSTALL_DIR" in
""|"/")
echo "Error: unsafe install directory: $INSTALL_DIR"
exit 1
;;
esac
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: required command not found: $1"
exit 1
fi
}
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return
fi
if command -v corepack >/dev/null 2>&1; then
corepack enable
fi
if ! command -v pnpm >/dev/null 2>&1; then
echo "Error: pnpm is required for --source installs. Install Node.js with corepack/pnpm first."
exit 1
fi
}
make_executable_if_present() {
if [[ -f "$1" ]]; then
chmod 0755 "$1"
fi
}
get_latest_version() {
echo "Fetching latest release version from Gitea..." >&2
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
local response
if ! response=$(curl -fsSL "$api_url" 2>/dev/null); then
echo "Error: Failed to fetch latest release information from Gitea API" >&2
echo "URL: $api_url" >&2
exit 1
fi
local version
version=$(printf '%s' "$response" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [[ -z "$version" ]]; then
echo "Error: Could not determine latest version from API response" >&2
exit 1
fi
echo "$version"
}
detect_binary_name() {
local os
local arch
os=$(uname -s)
arch=$(uname -m)
if [[ "$os" != "Linux" ]]; then
echo "Error: binary installer currently supports Linux only. Use --source for this platform." >&2
exit 1
fi
case "$arch" in
x86_64|amd64)
echo "dcrouter-linux-x64"
;;
aarch64|arm64)
echo "dcrouter-linux-arm64"
;;
*)
echo "Error: unsupported architecture for binary install: $arch. Use --source." >&2
exit 1
;;
esac
}
echo "================================================"
echo " DcRouter Installation Script"
echo "================================================"
echo ""
require_command curl
require_command sed
if [[ -n "$SPECIFIED_VERSION" ]]; then
VERSION="$SPECIFIED_VERSION"
echo "Installing specified version: $VERSION"
else
VERSION=$(get_latest_version)
echo "Installing latest version: $VERSION"
fi
echo "Install mode: $INSTALL_MODE"
echo ""
SOURCE_REF="$VERSION"
REPO_URL="${GITEA_BASE_URL}/${GITEA_REPO}.git"
TEMP_DIR=$(mktemp -d)
SOURCE_DIR="$TEMP_DIR/source"
BACKUP_DIR=""
SERVICE_WAS_RUNNING=0
SERVICE_STOPPED=0
SYSTEMD_AVAILABLE=0
cleanup_temp() {
rm -rf "$TEMP_DIR"
}
trap cleanup_temp EXIT
if command -v systemctl >/dev/null 2>&1; then
SYSTEMD_AVAILABLE=1
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
SERVICE_WAS_RUNNING=1
fi
fi
restore_previous_installation() {
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
echo "Restoring previous installation from $BACKUP_DIR..."
rm -rf "$INSTALL_DIR" || true
mv "$BACKUP_DIR" "$INSTALL_DIR" || true
if [[ -f "$INSTALL_DIR/dcrouter" ]]; then
mkdir -p "$BIN_DIR" || true
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter" || true
elif [[ -f "$INSTALL_DIR/cli.js" ]]; then
mkdir -p "$BIN_DIR" || true
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter" || true
fi
fi
}
restart_previous_service_on_error() {
if [[ $SERVICE_STOPPED -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
echo "Installation failed after stopping DcRouter; restarting previous service..."
systemctl start "$SERVICE_NAME" || true
fi
}
handle_install_error() {
trap - ERR
restore_previous_installation
restart_previous_service_on_error
}
trap handle_install_error ERR
stop_service_if_running() {
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
echo "Stopping DcRouter service..."
systemctl stop "$SERVICE_NAME"
SERVICE_STOPPED=1
fi
}
move_previous_installation() {
mkdir -p "$(dirname "$INSTALL_DIR")"
if [[ -d "$INSTALL_DIR" ]]; then
BACKUP_DIR="${INSTALL_DIR}.previous.$$"
echo "Moving previous installation to $BACKUP_DIR"
mv "$INSTALL_DIR" "$BACKUP_DIR"
fi
}
install_source_build() {
require_command git
require_command node
ensure_pnpm
echo "Cloning DcRouter source from $REPO_URL ($SOURCE_REF)..."
git clone --depth 1 --branch "$SOURCE_REF" "$REPO_URL" "$SOURCE_DIR"
echo "Installing dependencies..."
pnpm --dir "$SOURCE_DIR" install --frozen-lockfile
echo "Building DcRouter..."
pnpm --dir "$SOURCE_DIR" run build
echo "Validating built CLI..."
node "$SOURCE_DIR/cli.js" --version >/dev/null
stop_service_if_running
move_previous_installation
echo "Installing source build to $INSTALL_DIR"
mv "$SOURCE_DIR" "$INSTALL_DIR"
make_executable_if_present "$INSTALL_DIR/cli.js"
make_executable_if_present "$INSTALL_DIR/cli.ts.js"
make_executable_if_present "$INSTALL_DIR/cli.child.js"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter"
}
install_release_binary() {
local binary_name
local download_url
local temp_file
binary_name=$(detect_binary_name)
download_url="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${binary_name}"
temp_file="$TEMP_DIR/$binary_name"
echo "Downloading DcRouter binary: $download_url"
curl -fSL "$download_url" -o "$temp_file"
chmod 0755 "$temp_file"
echo "Validating downloaded binary..."
"$temp_file" --version >/dev/null
stop_service_if_running
move_previous_installation
echo "Installing binary to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
install -m 0755 "$temp_file" "$INSTALL_DIR/dcrouter"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter"
}
if [[ "$INSTALL_MODE" == "source" ]]; then
install_source_build
else
install_release_binary
fi
echo "Symlink created: $BIN_DIR/dcrouter"
if ! "$BIN_DIR/dcrouter" --version >/dev/null; then
echo "Error: Installed DcRouter CLI failed validation"
restore_previous_installation
restart_previous_service_on_error
exit 1
fi
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
rm -rf "$BACKUP_DIR"
fi
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
echo "Restarting DcRouter service..."
systemctl restart "$SERVICE_NAME"
SERVICE_STOPPED=0
echo "Service restarted successfully."
echo ""
fi
trap - ERR
echo "================================================"
echo " DcRouter Installation Complete!"
echo "================================================"
echo ""
echo "Installation details:"
echo " Install directory: $INSTALL_DIR"
echo " Symlink location: $BIN_DIR/dcrouter"
echo " Version: $VERSION"
echo " Mode: $INSTALL_MODE"
echo ""
echo "Get started:"
echo ""
echo " dcrouter --version"
echo " dcrouter --help"
echo ""
+49 -44
View File
@@ -1,9 +1,12 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.0.8",
"version": "14.1.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
"dcrouter": "./cli.js"
},
"exports": {
".": "./dist_ts/index.js",
"./interfaces": "./dist_ts_interfaces/index.js",
@@ -12,63 +15,68 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --logfile --timeout 60)",
"test": "(tstest test/ --verbose --logfile --timeout 60)",
"start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"build": "(tsbuild tsfolders --allowimplicitany && pnpm run bundle)",
"build:binary": "(pnpm run build && tsdeno compile)",
"build:docker": "tsdocker build --verbose",
"release:docker": "tsdocker push --verbose",
"bundle": "(tsbundle)",
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.2"
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdeno": "^1.5.0",
"@git.zone/tsdocker": "^2.4.3",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest": "^3.3.2",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@api.global/typedserver": "^8.4.7",
"@api.global/typedsocket": "^4.1.4",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.61.1",
"@design.estate/dees-catalog": "^3.84.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@idp.global/sdk": "^1.4.0",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.6",
"@push.rocks/smartdb": "^2.5.9",
"@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartdata": "^7.2.0",
"@push.rocks/smartdb": "^2.10.2",
"@push.rocks/smartdns": "^7.9.3",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartjwt": "^2.2.2",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartmigration": "1.4.1",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.4.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.12.8",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.1",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.11.2",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@serve.zone/catalog": "^2.13.0",
"@serve.zone/interfaces": "^6.3.0",
"@serve.zone/remoteingress": "^4.23.0",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.2.7",
"lru-cache": "^11.5.1",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"keywords": [
"mail service",
@@ -96,25 +104,22 @@
"VLAN assignment",
"MAC authentication"
],
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
},
"packageManager": "pnpm@10.11.0",
"files": [
"ts/**/*",
"binary/**/*",
"ts_web/**/*",
"ts_apiclient/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"dist_ts_apiclient/**/*",
"assets/**/*",
"cli.js",
"cli.ts.js",
"cli.child.js",
"cli.child.ts",
"deno.json",
"tsconfig.json",
".smartconfig.json",
"readme.md"
]
+2772 -2970
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@design.estate/dees-catalog'
- esbuild
- mongodb-memory-server
- puppeteer
+287 -1565
View File
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { TypedRequest } from '@api.global/typedrequest';
import { OpsServer } from '../ts/opsserver/index.js';
import { DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
const testPort = 3110;
const baseUrl = `http://localhost:${testPort}/typedrequest`;
const bootstrapPassword = 'temporary-bootstrap-password';
const persistedPassword = 'persisted-admin-password';
let previousAdminPassword: string | undefined;
let opsServer: OpsServer;
let testDb: DcRouterDb;
let storagePath: string;
let dbName: string;
let bootstrapIdentity: interfaces.data.IIdentity;
let persistedIdentity: interfaces.data.IIdentity;
let createdUserId: string;
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
baseUrl,
'getAdminBootstrapStatus',
);
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
baseUrl,
'adminLoginWithUsernameAndPassword',
);
const createFakeDcRouter = (portArg: number, dcRouterDbArg?: DcRouterDb) => ({
options: {
opsServerPort: portArg,
dbConfig: { enabled: true },
adminAuth: {
idpClient: {
loginWithEmailAndPassword: async () => ({
jwt: 'idp-jwt',
refreshToken: 'idp-refresh-token',
user: {
id: 'idp-user-1',
data: {
name: 'Wrong IdP User',
username: 'wrong@example.com',
email: 'wrong@example.com',
status: 'active',
connectedOrgs: [],
},
},
}),
stop: async () => {},
},
},
},
typedrouter: new plugins.typedrequest.TypedRouter(),
dcRouterDb: dcRouterDbArg,
});
const restartOpsServer = async () => {
await opsServer.stop();
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start();
};
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
testDb = DcRouterDb.getInstance({
storagePath,
dbName,
});
await testDb.start();
await testDb.getDb().mongoDb.createCollection('__test_init');
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
await opsServer.start();
});
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
const status = await createStatusRequest().fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(true);
expect(status.hasPersistentAdmin).toEqual(false);
expect(status.needsBootstrap).toEqual(true);
expect(status.ephemeralAdminAvailable).toEqual(true);
expect(status.idpGlobalConfigured).toEqual(true);
});
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
const response = await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
if (!response.identity) {
throw new Error('Expected bootstrap login identity');
}
bootstrapIdentity = response.identity;
expect(bootstrapIdentity.role).toEqual('admin');
});
tap.test('creates the initial persisted admin explicitly', async () => {
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
baseUrl,
'createInitialAdminUser',
);
const response = await request.fire({
identity: bootstrapIdentity,
email: 'Admin@Example.com',
name: 'Persisted Admin',
password: persistedPassword,
enableIdpGlobalAuth: true,
});
expect(response.success).toEqual(true);
expect(response.user?.role).toEqual('admin');
expect(response.user?.authSources).toContain('local');
expect(response.user?.authSources).toContain('idp.global');
if (!response.identity) {
throw new Error('Expected persisted admin identity');
}
persistedIdentity = response.identity;
});
tap.test('disables bootstrap mode after persisted admin exists', async () => {
const status = await createStatusRequest().fire({});
expect(status.hasPersistentAdmin).toEqual(true);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
});
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
baseUrl,
'verifyIdentity',
);
const response = await request.fire({ identity: bootstrapIdentity });
expect(response.valid).toEqual(false);
});
tap.test('authenticates the persisted admin locally by normalized email', async () => {
const response = await createLoginRequest().fire({
username: 'admin@example.com',
password: persistedPassword,
authSource: 'local',
});
if (!response.identity) {
throw new Error('Expected persisted admin login identity');
}
expect(response.identity.userId).toEqual(persistedIdentity.userId);
});
tap.test('persists users across OpsServer restart', async () => {
const oldPersistedIdentity = persistedIdentity;
await restartOpsServer();
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
baseUrl,
'verifyIdentity',
);
const verifyResponse = await verifyRequest.fire({ identity: oldPersistedIdentity });
expect(verifyResponse.valid).toEqual(false);
const loginResponse = await createLoginRequest().fire({
username: 'admin@example.com',
password: persistedPassword,
authSource: 'local',
});
if (!loginResponse.identity) {
throw new Error('Expected persisted admin login identity after restart');
}
expect(loginResponse.identity.userId).toEqual(oldPersistedIdentity.userId);
persistedIdentity = loginResponse.identity;
});
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin@example.com',
password: 'idp-password',
authSource: 'idp.global',
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('creates a persisted non-admin user explicitly', async () => {
const request = new TypedRequest<interfaces.requests.IReq_CreateUser>(baseUrl, 'createUser');
const response = await request.fire({
identity: persistedIdentity,
email: 'operator@example.com',
name: 'Operator User',
role: 'user',
password: 'operator-password',
});
expect(response.success).toEqual(true);
expect(response.user?.role).toEqual('user');
expect(response.user?.email).toEqual('operator@example.com');
if (!response.user?.id) {
throw new Error('Expected created user id');
}
createdUserId = response.user.id;
});
tap.test('rejects deleting the current persisted admin user', async () => {
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
const response = await request.fire({
identity: persistedIdentity,
id: persistedIdentity.userId,
});
expect(response.success).toEqual(false);
});
tap.test('deletes a persisted non-current user', async () => {
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
const response = await request.fire({
identity: persistedIdentity,
id: createdUserId,
});
expect(response.success).toEqual(true);
});
tap.test('lists persisted users without password material', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
const response = await request.fire({ identity: persistedIdentity });
expect(response.users.length).toEqual(1);
expect(response.users[0].email).toEqual('Admin@Example.com');
expect((response.users[0] as any).password).toBeUndefined();
});
tap.test('rejects temporary bootstrap admin when persisted-user database is unavailable', async () => {
await testDb.stop();
const status = await createStatusRequest().fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(false);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
let rejected = false;
try {
await createLoginRequest().fire({
username: 'admin',
password: bootstrapPassword,
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
});
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
await opsServer.stop();
await testDb.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
if (previousAdminPassword === undefined) {
delete process.env.DCROUTER_ADMIN_PASSWORD;
} else {
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
}
});
tap.test('does not offer bootstrap while configured database is unavailable', async () => {
const unavailablePort = 3111;
const unavailableBaseUrl = `http://localhost:${unavailablePort}/typedrequest`;
const previousUnavailableAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
process.env.DCROUTER_ADMIN_PASSWORD = 'unavailable-bootstrap-password';
DcRouterDb.resetInstance();
const unavailableOpsServer = new OpsServer(createFakeDcRouter(unavailablePort) as any);
try {
await unavailableOpsServer.start();
const status = await new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
unavailableBaseUrl,
'getAdminBootstrapStatus',
).fire({});
expect(status.dbEnabled).toEqual(true);
expect(status.dbReady).toEqual(false);
expect(status.needsBootstrap).toEqual(false);
expect(status.ephemeralAdminAvailable).toEqual(false);
let rejected = false;
try {
await new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
unavailableBaseUrl,
'adminLoginWithUsernameAndPassword',
).fire({
username: 'admin',
password: 'unavailable-bootstrap-password',
});
} catch {
rejected = true;
}
expect(rejected).toEqual(true);
} finally {
await unavailableOpsServer.stop();
DcRouterDb.resetInstance();
if (previousUnavailableAdminPassword === undefined) {
delete process.env.DCROUTER_ADMIN_PASSWORD;
} else {
process.env.DCROUTER_ADMIN_PASSWORD = previousUnavailableAdminPassword;
}
}
});
export default tap.start();
+75
View File
@@ -0,0 +1,75 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ApiTokenManager } from '../ts/config/classes.api-token-manager.js';
import { DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
tap.test('ApiTokenManager seeds and rotates an env admin API token', async () => {
const previousToken = process.env.DCROUTER_ADMIN_API_TOKEN;
const previousName = process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
const testDb = await createTestDb();
try {
const rawToken1 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
const rawToken2 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken1;
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = 'Onebox Managed Admin';
const manager = new ApiTokenManager();
await manager.initialize();
const token1 = await manager.validateToken(rawToken1);
expect(token1?.id).toEqual('env-admin-token');
expect(token1?.name).toEqual('Onebox Managed Admin');
expect(token1?.policy?.role).toEqual('admin');
expect(manager.hasScope(token1!, 'tokens:manage')).toEqual(true);
const listedToken = manager.listTokens().find((token) => token.id === 'env-admin-token') as any;
expect(listedToken.tokenHash).toBeUndefined();
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken2;
const rotatedManager = new ApiTokenManager();
await rotatedManager.initialize();
expect(await rotatedManager.validateToken(rawToken1)).toBeNull();
const token2 = await rotatedManager.validateToken(rawToken2);
expect(token2?.id).toEqual('env-admin-token');
expect(token2?.policy?.role).toEqual('admin');
} finally {
if (previousToken === undefined) {
delete process.env.DCROUTER_ADMIN_API_TOKEN;
} else {
process.env.DCROUTER_ADMIN_API_TOKEN = previousToken;
}
if (previousName === undefined) {
delete process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
} else {
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = previousName;
}
await testDb.cleanup();
}
});
export default tap.start();
+4 -46
View File
@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
match: { ports: 443, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
},
source: 'programmatic',
id: 'route-123',
enabled: true,
overridden: false,
storedRouteId: 'route-123',
origin: 'api',
createdAt: 1000,
updatedAt: 2000,
});
expect(route.name).toEqual('test-route');
expect(route.source).toEqual('programmatic');
expect(route.id).toEqual('route-123');
expect(route.enabled).toEqual(true);
expect(route.overridden).toEqual(false);
expect(route.storedRouteId).toEqual('route-123');
expect(route.origin).toEqual('api');
expect(route.routeConfig.match.ports).toEqual(443);
});
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const route = new Route(client, {
route: {
name: 'hardcoded-route',
match: { ports: 80 },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
},
source: 'hardcoded',
enabled: true,
overridden: false,
// No storedRouteId for hardcoded routes
});
let updateError: Error | undefined;
try {
await route.update({ name: 'new-name' });
} catch (e) {
updateError = e as Error;
}
expect(updateError).toBeTruthy();
expect(updateError!.message).toInclude('hardcoded');
let deleteError: Error | undefined;
try {
await route.delete();
} catch (e) {
deleteError = e as Error;
}
expect(deleteError).toBeTruthy();
let toggleError: Error | undefined;
try {
await route.toggle(false);
} catch (e) {
toggleError = e as Error;
}
expect(toggleError).toBeTruthy();
});
// =============================================================================
// Certificate resource class
// =============================================================================
+196
View File
@@ -0,0 +1,196 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { deriveCertDomainName } from '../ts/opsserver/handlers/certificate.handler.js';
// ──────────────────────────────────────────────────────────────────────────────
// deriveCertDomainName — pure helper that mirrors smartacme's certmatcher.
// Used by the force-renew sibling-propagation logic to identify which routes
// share a single underlying ACME certificate.
// ──────────────────────────────────────────────────────────────────────────────
tap.test('deriveCertDomainName collapses 3-level subdomain to base', async () => {
expect(deriveCertDomainName('outline.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('pr.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('mtd.task.vc')).toEqual('task.vc');
});
tap.test('deriveCertDomainName returns base domain unchanged for 2-level domain', async () => {
expect(deriveCertDomainName('task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('example.com')).toEqual('example.com');
});
tap.test('deriveCertDomainName strips wildcard prefix', async () => {
expect(deriveCertDomainName('*.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('*.example.com')).toEqual('example.com');
});
tap.test('deriveCertDomainName collapses subdomain and wildcard to same identity', async () => {
// This is the core property: outline.task.vc and *.task.vc must yield
// the same cert identity, otherwise sibling propagation cannot work.
const subdomain = deriveCertDomainName('outline.task.vc');
const wildcard = deriveCertDomainName('*.task.vc');
expect(subdomain).toEqual(wildcard);
});
tap.test('deriveCertDomainName returns undefined for 4+ level domains', async () => {
// Matches smartacme's "deeper domains not supported" behavior.
expect(deriveCertDomainName('a.b.task.vc')).toBeUndefined();
expect(deriveCertDomainName('one.two.three.example.com')).toBeUndefined();
});
tap.test('deriveCertDomainName returns undefined for malformed inputs', async () => {
expect(deriveCertDomainName('vc')).toBeUndefined();
expect(deriveCertDomainName('')).toBeUndefined();
});
// ──────────────────────────────────────────────────────────────────────────────
// CertificateHandler.reprovisionCertificateDomain — verify the includeWildcard
// option is forwarded to smartAcme.getCertificateForDomain on force renew.
//
// This is the regression test for Bug 1: previously the call passed only
// `{ forceRenew: true }`, causing the re-issued cert to drop the wildcard SAN
// and break every sibling subdomain.
// ──────────────────────────────────────────────────────────────────────────────
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
// Build a minimal stub of OpsServer + DcRouter that satisfies CertificateHandler.
// We only need: viewRouter.addTypedHandler / adminRouter.addTypedHandler (no-op),
// dcRouterRef.smartProxy.routeManager.getRoutes(), dcRouterRef.smartAcme,
// dcRouterRef.findRouteNamesForDomain, dcRouterRef.certificateStatusMap.
function makeStubOpsServer(opts: {
routes: Array<{ name: string; domains: string[] }>;
smartAcmeStub: { getCertificateForDomain: (domain: string, options: any) => Promise<any> };
}) {
const captured: { typedHandlers: any[] } = { typedHandlers: [] };
const router = {
addTypedHandler(handler: any) { captured.typedHandlers.push(handler); },
};
const routes = opts.routes.map((r) => ({
name: r.name,
match: { domains: r.domains, ports: 443 },
action: { type: 'forward', tls: { certificate: 'auto' } },
}));
const dcRouterRef: any = {
smartProxy: {
routeManager: { getRoutes: () => routes },
},
smartAcme: opts.smartAcmeStub,
findRouteNamesForDomain: (domain: string) =>
routes.filter((r) => r.match.domains.includes(domain)).map((r) => r.name),
certificateStatusMap: new Map<string, any>(),
certProvisionScheduler: null,
routeConfigManager: null,
};
const opsServerRef: any = {
viewRouter: router,
adminRouter: router,
dcRouterRef,
};
return { opsServerRef, dcRouterRef, captured };
}
tap.test('reprovisionCertificateDomain passes includeWildcard=true for non-wildcard domain', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [
{ name: 'outline-route', domains: ['outline.task.vc'] },
{ name: 'pr-route', domains: ['pr.task.vc'] },
{ name: 'mtd-route', domains: ['mtd.task.vc'] },
],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
// Return a cert object shaped like SmartacmeCert
return {
id: 'test-id',
domainName: 'task.vc',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
};
},
},
});
// Override updateRoutes/applyRoutes to no-op so the test doesn't try to talk to a real proxy
dcRouterRef.smartProxy.updateRoutes = async () => {};
// Construct handler — registerHandlers will run and register typed handlers on our stub router.
const handler = new CertificateHandler(opsServerRef);
// Invoke the private reprovision method directly. The Bug 1 fix is verified
// by inspecting the captured smartAcme call options regardless of whether
// sibling propagation succeeds (it relies on a real DB for ProxyCertDoc).
await (handler as any).reprovisionCertificateDomain('outline.task.vc', true);
// Sibling propagation may fail because ProxyCertDoc.findByDomain needs a real DB.
// The Bug 1 fix is verified by the captured smartAcme call regardless.
expect(calls.length).toBeGreaterThanOrEqual(1);
expect(calls[0].domain).toEqual('outline.task.vc');
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: true });
});
tap.test('reprovisionCertificateDomain passes includeWildcard=false for wildcard domain', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [
{ name: 'wildcard-route', domains: ['*.task.vc'] },
],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
return {
id: 'test-id',
domainName: 'task.vc',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
};
},
},
});
dcRouterRef.smartProxy.updateRoutes = async () => {};
const handler = new CertificateHandler(opsServerRef);
await (handler as any).reprovisionCertificateDomain('*.task.vc', true);
expect(calls.length).toBeGreaterThanOrEqual(1);
expect(calls[0].domain).toEqual('*.task.vc');
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: false });
});
tap.test('reprovisionCertificateDomain does not call smartAcme when forceRenew is false', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [{ name: 'outline-route', domains: ['outline.task.vc'] }],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
return {} as any;
},
},
});
dcRouterRef.smartProxy.updateRoutes = async () => {};
const handler = new CertificateHandler(opsServerRef);
await (handler as any).reprovisionCertificateDomain('outline.task.vc', false);
// forceRenew=false should NOT call getCertificateForDomain — it just triggers
// applyRoutes and lets the cert provisioning pipeline handle it.
expect(calls.length).toEqual(0);
});
export default tap.start();
+201
View File
@@ -0,0 +1,201 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
import { AcmeCertDoc, DcRouterDb } from '../ts/db/index.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-cert-api-token-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const makeApiTokenManager = (scopes: TScope[]) => {
const token = {
id: 'token-1',
name: 'certificate-test-token',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope),
};
};
const setupHandler = (scopes: TScope[], options?: {
routes?: any[];
certProvisionScheduler?: any;
certProvisionFunction?: (...args: any[]) => any;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = {
typedrouter,
adminHandler: {
validateIdentity: async () => null,
adminIdentityGuard: {
exec: async () => false,
},
},
dcRouterRef: {
apiTokenManager: makeApiTokenManager(scopes),
certificateStatusMap: new Map(),
smartProxy: {
settings: options?.certProvisionFunction ? {
certProvisionFunction: options.certProvisionFunction,
} : {},
routeManager: { getRoutes: () => options?.routes ?? [] },
getCertificateStatus: async () => null,
},
certProvisionScheduler: options?.certProvisionScheduler ?? null,
},
};
new CertificateHandler(opsServerRef);
return { typedrouter, opsServerRef };
};
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const testDbPromise = createTestDb();
tap.test('CertificateHandler allows API-token export with certificates:read', async () => {
await testDbPromise;
const certDoc = new AcmeCertDoc();
certDoc.id = 'cert-1';
certDoc.domainName = 'example.com';
certDoc.created = 1;
certDoc.validUntil = 2;
certDoc.privateKey = '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----';
certDoc.publicKey = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----';
certDoc.csr = '';
await certDoc.save();
const { typedrouter } = setupHandler(['certificates:read']);
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
apiToken: 'valid-token',
domain: 'example.com',
});
expect(result.error).toBeUndefined();
expect(result.response.success).toEqual(true);
expect(result.response.cert.domainName).toEqual('example.com');
expect(result.response.cert.privateKey).toContain('BEGIN PRIVATE KEY');
expect(result.response.cert.publicKey).toContain('BEGIN CERTIFICATE');
});
tap.test('CertificateHandler rejects API-token export without certificates:read', async () => {
const { typedrouter } = setupHandler(['certificates:write']);
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
apiToken: 'valid-token',
domain: 'example.com',
});
expect(result.error?.text).toEqual('insufficient scope');
});
tap.test('CertificateHandler allows API-token import with certificates:write', async () => {
await testDbPromise;
const { typedrouter, opsServerRef } = setupHandler(['certificates:write']);
const result = await fireTypedRequest(typedrouter, 'importCertificate', {
apiToken: 'valid-token',
cert: {
id: 'cert-2',
domainName: 'imported.example.com',
created: 3,
validUntil: 4,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
},
});
expect(result.error).toBeUndefined();
expect(result.response.success).toEqual(true);
expect((await AcmeCertDoc.findByDomain('imported.example.com'))?.id).toEqual('cert-2');
expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid');
});
tap.test('CertificateHandler reports active certificate backoff as failed with root cause', async () => {
await testDbPromise;
const lastError = 'DNS-01 failed for stack.gallery: DnsManager: no managed domain found for _acme-challenge.stack.gallery.';
const retryAfter = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const { typedrouter } = setupHandler(['certificates:read'], {
certProvisionFunction: async () => 'http01',
certProvisionScheduler: {
getBackoffInfo: async (domain: string) => domain === 'stack.gallery'
? { failures: 11, retryAfter, lastError }
: null,
},
routes: [
{
name: 'stack-gallery',
match: { domains: ['stack.gallery'] },
action: {
tls: {
mode: 'terminate',
certificate: 'auto',
},
},
},
],
});
const result = await fireTypedRequest(typedrouter, 'getCertificateOverview', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.summary.failed).toEqual(1);
expect(result.response.certificates[0].status).toEqual('failed');
expect(result.response.certificates[0].error).toEqual(lastError);
expect(result.response.certificates[0].backoffInfo.failures).toEqual(11);
});
tap.test('cleanup test db', async () => {
const testDb = await testDbPromise;
await testDb.cleanup();
});
export default tap.start();
+79
View File
@@ -0,0 +1,79 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ConfigHandler } from '../ts/opsserver/handlers/config.handler.js';
import * as plugins from '../ts/plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
const fireTypedRequest = async (
router: plugins.typedrequest.TypedRouter,
method: string,
request: Record<string, any>,
) => {
return await router.routeAndAddResponse({
method,
request,
response: {},
correlation: {
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
phase: 'request',
},
} as any, { localRequest: true, skipHooks: true }) as any;
};
const makeOpsServer = (scopes: interfaces.data.TApiTokenScope[]) => {
const router = new plugins.typedrequest.TypedRouter();
const token = {
id: 'token-1',
name: 'config-token',
tokenHash: 'hash',
scopes,
createdBy: 'token-user',
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
enabled: true,
} as interfaces.data.IStoredApiToken;
const opsServerRef = {
viewRouter: router,
adminHandler: {
validateIdentity: async () => null,
},
dcRouterRef: {
options: {
dbConfig: { enabled: false },
},
resolvedPaths: {
dcrouterHomeDir: '/tmp/dcrouter-home',
dataDir: '/tmp/dcrouter-data',
defaultTsmDbPath: '/tmp/dcrouter-data/db',
},
detectedPublicIp: null,
apiTokenManager: {
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: interfaces.data.TApiTokenScope) => storedTokenArg.scopes.includes(scopeArg),
},
},
} as any;
new ConfigHandler(opsServerRef);
return router;
};
tap.test('ConfigHandler accepts API token with config:read', async () => {
const router = makeOpsServer(['config:read']);
const result = await fireTypedRequest(router, 'getConfiguration', {
apiToken: 'valid-token',
});
expect(result.error).toBeUndefined();
expect(result.response.config.system.baseDir).toEqual('/tmp/dcrouter-home');
});
tap.test('ConfigHandler rejects API token without config:read', async () => {
const router = makeOpsServer(['logs:read']);
const result = await fireTypedRequest(router, 'getConfiguration', {
apiToken: 'valid-token',
});
expect(result.error?.text).toEqual('insufficient scope');
});
export default tap.start();
+308 -1
View File
@@ -2,9 +2,84 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as path from 'path';
import * as fs from 'fs';
import { Buffer } from 'node:buffer';
import * as net from 'node:net';
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function listen(server: net.Server, port: number = 0): Promise<number> {
return await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', () => {
server.off('error', reject);
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : port);
});
});
}
function trackSocket(sockets: Set<net.Socket>, socket: net.Socket): void {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
}
async function closeServer(server: net.Server, sockets?: Set<net.Socket>): Promise<void> {
for (const socket of sockets || []) {
socket.destroy();
}
if (!server.listening) {
return;
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
async function readFirstSocketData(port: number): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const socket = net.connect({ host: '127.0.0.1', port });
let settled = false;
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', onData);
socket.removeListener('error', onError);
socket.removeListener('end', onEnd);
socket.removeListener('close', onClose);
};
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
cleanup();
socket.destroy();
callback();
};
timeout = setTimeout(() => {
settle(() => reject(new Error('Timed out waiting for socket data')));
}, 5000) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
const onData = (data: Buffer) => settle(() => resolve(data.toString('utf8')));
const onError = (error: Error) => settle(() => reject(error));
const onEnd = () => settle(() => reject(new Error('Socket ended before data')));
const onClose = () => settle(() => reject(new Error('Socket closed before data')));
socket.once('data', onData);
socket.once('error', onError);
socket.once('end', onEnd);
socket.once('close', onClose);
});
}
tap.test('DcRouter class - Custom email port configuration', async () => {
// Define custom port mapping
@@ -97,7 +172,10 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
});
expect(customPortRoute).toBeTruthy();
expect(customPortRoute?.name).toEqual('custom-smtp-route');
expect(customPortRoute?.action.type).toEqual('forward');
expect(customPortRoute?.action.targets[0].host).toEqual('localhost');
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
expect(customPortRoute?.remoteIngress).toBeUndefined();
// Check standard port mappings
const smtpRoute = routes.find((r: any) => {
@@ -114,7 +192,185 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
}
});
tap.test('DcRouter class - Generated plaintext email routes hydrate to server-first socket handlers', async () => {
const emailConfig: IUnifiedEmailServerOptions = {
ports: [25, 587, 465],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({ emailConfig });
const routes = (router as any)['generateEmailRoutes'](emailConfig);
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
const hydrate = (routerArg: DcRouter, route: any, origin = 'email') => (routerArg as any)['hydrateStoredRouteForRuntime']({
id: `${origin}-${route.name}`,
route,
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'system',
origin,
systemKey: `${origin}:${route.name}`,
});
const runtimeSmtpRoute = hydrate(router, smtpRoute);
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
const runtimeSubmissionRoute = hydrate(router, submissionRoute);
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
expect(hydrate(router, smtpsRoute)).toBeUndefined();
expect(hydrate(router, smtpRoute, 'api')).toBeUndefined();
const remoteIngressRouter = new DcRouter({
emailConfig,
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const staleSmtpRoute = {
...smtpRoute,
match: {
...smtpRoute.match,
inboundProxyProtocol: undefined,
},
};
const runtimeRemoteSmtpRoute = hydrate(remoteIngressRouter, staleSmtpRoute);
expect(runtimeRemoteSmtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
});
tap.test('DcRouter class - Inbound PROXY policies are applied per listener', async () => {
const router = new DcRouter({
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const routes = (router as any)['applyInboundProxyProtocolPolicies']([{
name: 'remote-route',
match: { ports: [443], domains: ['remote.example.com'] },
remoteIngress: { enabled: true },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
}, {
name: 'same-listener-direct-route',
match: { ports: [443], domains: ['direct.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 9443 }],
},
}]);
expect(routes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
expect(routes[1].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
const vpnRouter = new DcRouter({
vpnConfig: { enabled: true },
});
const vpnRoutes = (vpnRouter as any)['applyInboundProxyProtocolPolicies']([{
name: 'vpn-route',
match: { ports: [9443] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 9443 }],
},
}]);
expect(vpnRoutes[0].match.inboundProxyProtocol).toEqual({ mode: 'optional' });
});
tap.test('DcRouter class - Email socket handler relays server-first SMTP banners', async () => {
const backendSockets = new Set<net.Socket>();
const backend = net.createServer((socket) => {
trackSocket(backendSockets, socket);
socket.write('220 test.example ESMTP Service Ready\r\n');
});
const backendPort = await listen(backend);
const emailConfig: IUnifiedEmailServerOptions = {
ports: [2525],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({
emailConfig,
emailPortConfig: {
portMapping: { 2525: backendPort },
},
});
const routes = (router as any)['generateEmailRoutes'](emailConfig);
const route = routes.find((routeArg: any) => routeArg.name === 'email-port-2525-route');
const runtimeRoute = (router as any)['createServerFirstEmailRuntimeRoute'](route);
expect(runtimeRoute?.action.type).toEqual('socket-handler');
const frontendSockets = new Set<net.Socket>();
const frontend = net.createServer((socket) => {
trackSocket(frontendSockets, socket);
runtimeRoute.action.socketHandler(socket, {
port: 2525,
clientIp: '127.0.0.1',
serverIp: '127.0.0.1',
routeName: route.name,
timestamp: Date.now(),
connectionId: 'test-email-proxy',
});
});
const frontendPort = await listen(frontend);
try {
const banner = await readFirstSocketData(frontendPort);
expect(banner).toEqual('220 test.example ESMTP Service Ready\r\n');
} finally {
await closeServer(frontend, frontendSockets);
await closeServer(backend, backendSockets);
}
});
tap.test('DcRouter class - Email routes are exposed through RemoteIngress when enabled', async () => {
const emailConfig: IUnifiedEmailServerOptions = {
ports: [25, 587, 465],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({
emailConfig,
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const routes = (router as any)['generateEmailRoutes'](emailConfig);
expect(routes.length).toEqual(3);
for (const route of routes) {
expect(route.remoteIngress).toEqual({ enabled: true });
}
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
expect(smtpRoute?.match.transport).toEqual('tcp');
expect(smtpRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
expect(submissionRoute?.match.transport).toEqual('tcp');
expect(submissionRoute?.match.inboundProxyProtocol).toEqual({ mode: 'required' });
expect(smtpsRoute?.action.type).toEqual('forward');
expect(smtpsRoute?.match.inboundProxyProtocol).toEqual({ mode: 'optional' });
});
tap.test('DcRouter class - Email config with domains and routes', async () => {
const opsServerPort = await getFreePort();
// Create a basic email configuration
const emailConfig: IUnifiedEmailServerOptions = {
ports: [2525],
@@ -129,7 +385,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
tls: {
contactEmail: 'test@example.com'
},
opsServerPort: 3104,
opsServerPort,
dbConfig: {
enabled: false,
}
@@ -143,11 +399,62 @@ 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();
});
tap.test('DcRouter class - Email config updates are serialized', async () => {
const router = new DcRouter({
tls: {
contactEmail: 'test@example.com',
},
});
const delay = async () => await new Promise<void>((resolve) => setTimeout(resolve, 10));
let activeLifecycleSteps = 0;
let overlapped = false;
const enterLifecycleStep = async () => {
activeLifecycleSteps++;
if (activeLifecycleSteps > 1) {
overlapped = true;
}
await delay();
activeLifecycleSteps--;
};
(router as any).stopUnifiedEmailComponents = async () => {
await enterLifecycleStep();
};
(router as any).setupUnifiedEmailHandling = async () => {
await enterLifecycleStep();
};
const firstConfig: IUnifiedEmailServerOptions = {
ports: [2525],
hostname: 'first.mail.example.com',
domains: [],
routes: [],
};
const secondConfig: IUnifiedEmailServerOptions = {
ports: [2526],
hostname: 'second.mail.example.com',
domains: [],
routes: [],
};
await Promise.all([
router.updateEmailConfig(firstConfig),
router.updateEmailConfig(secondConfig),
]);
expect(overlapped).toEqual(false);
expect(router.options.emailConfig?.hostname).toEqual('second.mail.example.com');
});
// Final clean-up test
tap.test('clean up after tests', async () => {
// No-op
+436
View File
@@ -0,0 +1,436 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DnsRecordDoc, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DnsManager } from '../ts/dns/manager.dns.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const record of await DnsRecordDoc.findAll()) {
await record.delete();
}
for (const route of await RouteDoc.findAll()) {
await route.delete();
}
for (const domain of await DomainDoc.findAll()) {
await domain.delete();
}
};
tap.test('DnsManager keeps parallel ACME TXT challenges for the same host', async () => {
await testDbPromise;
await clearTestState();
const now = Date.now();
const domain = new DomainDoc();
domain.id = 'central-eu';
domain.name = 'central.eu';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'test';
await domain.save();
const dnsManager = new DnsManager({});
const provider = dnsManager.buildAcmeConvenientDnsProvider().convenience as any;
const hostName = '_acme-challenge.blog.central.eu';
await provider.acmeSetDnsChallenge({ hostName, challenge: 'first-token' });
await provider.acmeSetDnsChallenge({ hostName, challenge: 'second-token' });
const recordsAfterSet = await DnsRecordDoc.findByDomainId(domain.id);
expect(recordsAfterSet.map((record) => record.value).sort()).toEqual([
'first-token',
'second-token',
]);
await provider.acmeRemoveDnsChallenge({ hostName, challenge: 'first-token' });
const recordsAfterRemove = await DnsRecordDoc.findByDomainId(domain.id);
expect(recordsAfterRemove.map((record) => record.value)).toEqual(['second-token']);
});
tap.test('DnsManager local records answer mixed-case DNS queries', async () => {
await testDbPromise;
await clearTestState();
const now = Date.now();
const domain = new DomainDoc();
domain.id = 'central-eu';
domain.name = 'central.eu';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'test';
await domain.save();
const registeredHandlers: Array<(question: { name: string; type: string }) => any> = [];
const dnsManager = new DnsManager({});
dnsManager.dnsServer = {
registerHandler: (_name: string, _types: string[], handler: (question: { name: string; type: string }) => any) => {
registeredHandlers.push(handler);
},
} as any;
await dnsManager.createRecord({
domainId: domain.id,
name: '_acme-challenge.central.eu',
type: 'TXT',
value: 'challenge-token',
ttl: 120,
createdBy: 'test',
});
const answer = registeredHandlers[0]?.({
name: '_aCMe-challeNge.Central.Eu',
type: 'txt',
});
expect(answer).toEqual({
name: '_aCMe-challeNge.Central.Eu',
type: 'TXT',
class: 'IN',
ttl: 120,
data: 'challenge-token',
});
});
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const persistedRoutes = await RouteDoc.findAll();
expect(persistedRoutes.length).toEqual(2);
expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
const mergedRoutes = routeManager.getMergedRoutes().routes;
expect(mergedRoutes.length).toEqual(2);
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
expect(appliedRoutes.length).toEqual(1);
for (const routeSet of appliedRoutes) {
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
expect(dnsQueryRoute).toBeDefined();
expect(resolveRoute).toBeDefined();
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
}
});
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const staleDnsQueryRoute = new RouteDoc();
staleDnsQueryRoute.id = 'stale-doh-query';
staleDnsQueryRoute.route = {
name: 'dns-over-https-dns-query',
match: {
ports: [443],
domains: ['ns1.example.com'],
path: '/dns-query',
},
action: {
type: 'socket-handler' as any,
} as any,
};
staleDnsQueryRoute.enabled = true;
staleDnsQueryRoute.createdAt = Date.now();
staleDnsQueryRoute.updatedAt = Date.now();
staleDnsQueryRoute.createdBy = 'test';
staleDnsQueryRoute.origin = 'dns';
await staleDnsQueryRoute.save();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const remainingRoutes = await RouteDoc.findAll();
expect(remainingRoutes.length).toEqual(2);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
expect(queryRoute?.id).toEqual('stale-doh-query');
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
});
tap.test('RouteConfigManager only allows toggling system routes', async () => {
await testDbPromise;
await clearTestState();
const smartProxy = {
updateRoutes: async (_routes: any[]) => {
return;
},
};
const routeManager = new RouteConfigManager(() => smartProxy as any);
await routeManager.initialize([
{
name: 'system-config-route',
match: {
ports: [443],
domains: ['app.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
tls: { mode: 'terminate' as const },
},
} as any,
], [], []);
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
expect(systemRoute).toBeDefined();
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
route: { name: 'renamed-system-route' } as any,
});
expect(updateResult.success).toEqual(false);
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
expect(deleteResult.success).toEqual(false);
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
expect(toggleResult.success).toEqual(true);
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
});
tap.test('RouteConfigManager clears a network target ref and keeps the edited inline target port', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const resolver = new ReferenceResolver();
(resolver as any).targets.set('target-1', {
id: 'target-1',
name: 'SSH TARGET',
host: '10.0.0.5',
port: 443,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'test',
});
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
resolver,
);
await routeManager.initialize([], [], []);
const routeId = await routeManager.createRoute(
{
name: 'ssh-route',
match: { ports: [22] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 22 }],
},
} as any,
'test-user',
true,
{ networkTargetRef: 'target-1' },
);
expect((await RouteDoc.findById(routeId))?.route.action.targets?.[0].port).toEqual(443);
expect((await RouteDoc.findById(routeId))?.metadata?.networkTargetRef).toEqual('target-1');
const updateResult = await routeManager.updateRoute(routeId, {
route: {
action: {
targets: [{ host: '127.0.0.1', port: 29424 }],
},
} as any,
metadata: {
networkTargetRef: '',
networkTargetName: '',
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.action.targets?.[0].host).toEqual('127.0.0.1');
expect(storedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(storedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(storedRoute?.metadata?.networkTargetName).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(mergedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(mergedRoute?.metadata?.networkTargetName).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].action.targets[0].port).toEqual(29424);
});
tap.test('RouteConfigManager clears remote ingress config when route patch sets it to null', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
);
await routeManager.initialize([], [], []);
const routeId = await routeManager.createRoute(
{
name: 'remote-ingress-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
remoteIngress: {
enabled: true,
edgeFilter: ['edge-a', 'blue'],
},
} as any,
'test-user',
);
const updateResult = await routeManager.updateRoute(routeId, {
route: {
remoteIngress: null,
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.remoteIngress).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.remoteIngress).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
});
tap.test('DnsManager start does not seed constructor DNS config into DB', async () => {
await testDbPromise;
await clearTestState();
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(await DomainDoc.findAll()).toHaveLength(0);
expect(await DnsRecordDoc.findAll()).toHaveLength(0);
});
tap.test('cleanup test db', async () => {
await clearTestState();
const testDb = await testDbPromise;
await testDb.cleanup();
});
export default tap.start();
+16 -2
View File
@@ -1,15 +1,29 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import * as plugins from '../ts/plugins.js';
import * as net from 'node:net';
let dcRouter: DcRouter;
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
const opsServerPort = await getFreePort();
dcRouter = new DcRouter({
smartProxyConfig: {
routes: []
},
opsServerPort: 3100,
opsServerPort,
dbConfig: { enabled: false }
});
@@ -146,4 +160,4 @@ tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();
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();
+237
View File
@@ -0,0 +1,237 @@
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('EmailDomainManager can resync managed domains after email settings replace runtime config', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('resync-domain', 'resync.example.com', 'provider');
const stored = new EmailDomainDoc();
stored.id = 'resync-email-domain';
stored.domain = 'mail.resync.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();
expect(dcRouterStub.options.emailConfig.domains.some((domain) => domain.domain === 'mail.resync.example.com')).toEqual(true);
dcRouterStub.options.emailConfig = createBaseEmailConfig();
manager.setBaseEmailDomains(dcRouterStub.options.emailConfig.domains);
await manager.syncManagedDomainsToRuntime();
const resyncedDomains = dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain).sort();
expect(resyncedDomains).toEqual(['mail.resync.example.com', 'static.example.com']);
});
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();
+135
View File
@@ -0,0 +1,135 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { DcRouterDb, EmailServerSettingsDoc } from '../ts/db/index.js';
import { EmailSettingsManager } from '../ts/email/index.js';
import type { IDcRouterOptions } from '../ts/classes.dcrouter.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-email-settings-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-email-settings-${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 clearSettings = async () => {
for (const doc of await EmailServerSettingsDoc.findAll()) {
await doc.delete();
}
};
tap.test('EmailSettingsManager does not backfill from legacy constructor options', async () => {
await testDbPromise;
await clearSettings();
const options: IDcRouterOptions = {
emailConfig: {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
maxMessageSize: 1024,
},
emailPortConfig: {
portMapping: { 25: 10025, 587: 10587 },
},
};
const manager = new EmailSettingsManager(options);
await manager.start();
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(manager.getPublicSettings().hostname).toEqual(null);
expect(options.emailConfig).toBeUndefined();
expect(options.emailPortConfig).toBeUndefined();
await clearSettings();
const migratedDoc = new EmailServerSettingsDoc();
migratedDoc.settingsId = 'email-server-settings';
migratedDoc.enabled = true;
migratedDoc.emailConfig = {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
maxMessageSize: 1024,
};
migratedDoc.emailPortConfig = {
portMapping: { 25: 10025, 587: 10587 },
};
migratedDoc.updatedAt = Date.now();
migratedDoc.updatedBy = 'migration';
await migratedDoc.save();
const secondOptions: IDcRouterOptions = {
emailConfig: {
hostname: 'ignored.example.com',
ports: [2525],
domains: [],
routes: [],
},
};
const secondManager = new EmailSettingsManager(secondOptions);
await secondManager.start();
expect(secondManager.getPublicSettings().hostname).toEqual('mail.example.com');
expect(secondOptions.emailConfig?.hostname).toEqual('mail.example.com');
});
tap.test('EmailSettingsManager updates redacted mutable server settings', async () => {
await testDbPromise;
await clearSettings();
const options: IDcRouterOptions = {};
const manager = new EmailSettingsManager(options);
await manager.start();
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(options.emailConfig).toBeUndefined();
const settings = await manager.updateSettings(
{
enabled: true,
hostname: 'smtp.example.com',
ports: [587, 25, 587],
portMapping: { 25: 10025, 587: 10587 },
maxMessageSize: 2048,
},
'tester',
);
expect(settings.enabled).toEqual(true);
expect(settings.ports).toEqual([25, 587]);
expect(settings.portMapping?.[587]).toEqual(10587);
expect(options.emailConfig?.hostname).toEqual('smtp.example.com');
expect(options.emailConfig?.maxMessageSize).toEqual(2048);
await manager.updateSettings({ enabled: false }, 'tester');
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(options.emailConfig).toBeUndefined();
});
tap.test('cleanup', async () => {
const testDb = await testDbPromise;
await clearSettings();
await testDb.cleanup();
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
}
+232
View File
@@ -0,0 +1,232 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '@push.rocks/smartproxy';
import * as http from 'node:http';
import * as net from 'node:net';
import {
deriveHttpRedirectConfiguration,
deriveHttpRedirects,
} from '../ts/config/helpers.http-redirects.js';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function requestHeaders(
port: number,
path: string,
headers?: Record<string, string>,
): Promise<http.IncomingMessage> {
return await new Promise<http.IncomingMessage>((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
request.once('error', reject);
});
}
tap.test('deriveHttpRedirectConfiguration creates active runtime redirects from HTTPS routes', async () => {
const result = deriveHttpRedirectConfiguration([
{
id: 'route-1',
name: 'app-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
remoteIngress: {
enabled: true,
edgeFilter: ['edge-a'],
},
} as any,
]);
expect(result.redirects.length).toEqual(1);
expect(result.redirects[0].status).toEqual('active');
expect(result.redirects[0].domainPattern).toEqual('app.example.com');
expect(result.redirects[0].remoteIngress).toEqual(true);
expect(result.runtimeRoutes.length).toEqual(1);
expect(result.runtimeRoutes[0].match.ports).toEqual(80);
expect(result.runtimeRoutes[0].match.domains).toEqual('app.example.com');
expect(result.runtimeRoutes[0].priority).toEqual(0);
expect(result.runtimeRoutes[0].remoteIngress).toEqual({ enabled: true, edgeFilter: ['edge-a'] });
expect(typeof result.runtimeRoutes[0].action.socketHandler).toEqual('function');
});
tap.test('deriveHttpRedirectConfiguration deduplicates identical redirect scopes', async () => {
const redirects = deriveHttpRedirects([
{
id: 'route-1',
name: 'first-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
id: 'route-2',
name: 'second-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8081 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
]);
expect(redirects.length).toEqual(1);
expect(redirects[0].sourceRouteNames).toEqual(['first-route', 'second-route']);
});
tap.test('deriveHttpRedirectConfiguration treats broad explicit HTTP routes as covered', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'existing-http-route',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects.length).toEqual(1);
expect(result.redirects[0].status).toEqual('covered');
expect(result.redirects[0].coveredByRouteNames).toEqual(['existing-http-route']);
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration skips broad redirects that overlap path-specific HTTP routes', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'existing-http-health-route',
match: { ports: 80, domains: 'app.example.com', path: '/health' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects[0].status).toEqual('skipped');
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration skips wildcard redirects that overlap explicit HTTP domains', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'wildcard-https-route',
match: { ports: 443, domains: '*.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'explicit-http-app-route',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects[0].status).toEqual('skipped');
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration ignores non-web or narrowed HTTPS routes', async () => {
const redirects = deriveHttpRedirects([
{
name: 'udp-route',
match: { ports: 443, domains: 'udp.example.com', transport: 'udp' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 443 }],
tls: { mode: 'passthrough' },
},
} as any,
{
name: 'header-route',
match: { ports: 443, domains: 'header.example.com', headers: { 'x-test': 'yes' } },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'socket-handler-route',
match: { ports: 443, domains: 'handler.example.com' },
action: {
type: 'socket-handler',
socketHandler: () => {},
},
} as any,
]);
expect(redirects.length).toEqual(0);
});
tap.test('generated runtime redirect preserves host and path', async () => {
const proxyPort = await getFreePort();
const redirectRoute = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
]).runtimeRoutes[0] as any;
redirectRoute.match = { ...redirectRoute.match, ports: proxyPort };
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [redirectRoute],
});
try {
await proxy.start();
const response = await requestHeaders(proxyPort, '/some/path?x=1', { host: 'app.example.com' });
expect(response.statusCode).toEqual(301);
expect(response.headers.location).toEqual('https://app.example.com/some/path?x=1');
response.destroy();
} finally {
await proxy.stop();
}
});
export default tap.start();
+54 -21
View File
@@ -2,14 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let identity: interfaces.data.IIdentity;
let opsServerPort: number;
const testAdminPassword = 'test-admin-password';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function getTypedRequestUrl(): string {
return `http://localhost:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3102,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -19,30 +40,34 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login with admin credentials and receive JWT', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
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);
});
tap.test('should verify valid JWT identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -53,12 +78,16 @@ 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 () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -75,7 +104,7 @@ tap.test('should reject invalid JWT', async () => {
tap.test('should verify JWT matches identity data', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -86,13 +115,17 @@ 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 () => {
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLogout'
);
@@ -106,7 +139,7 @@ tap.test('should handle logout', async () => {
tap.test('should reject wrong credentials', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -129,4 +162,4 @@ tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();
export default tap.start();
+378
View File
@@ -0,0 +1,378 @@
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 createActiveConnectionSnapshots(entries: Array<{
count: number;
sourceIp?: string;
routeId?: string;
domain?: string;
localPort?: number;
}>) {
const snapshots: any[] = [];
let index = 0;
for (const entry of entries) {
for (let i = 0; i < entry.count; i++) {
snapshots.push({
id: `test-connection-${index++}`,
sourceIp: entry.sourceIp || '192.0.2.10',
sourcePort: 40000 + index,
localPort: entry.localPort || 443,
domain: entry.domain,
routeId: entry.routeId,
targetHost: '127.0.0.1',
targetPort: 8443,
protocol: 'https',
state: 'active',
startedAtMs: Date.now(),
ageMs: 0,
bytesIn: 0,
bytesOut: 0,
});
}
}
return snapshots;
}
function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>;
domainRequestsByIP: Map<string, Map<string, number>>;
domainRequestRates?: Map<string, { perSecond: number; lastMinute: number }>;
backendMetrics?: Map<string, any>;
protocolCache?: any[];
requestsTotal?: number;
connectionsByIP?: Map<string, number>;
throughputByIP?: Map<string, { in: number; out: number }>;
}) {
const connectionsByIP = args.connectionsByIP || new Map<string, number>();
const throughputByIP = args.throughputByIP || new Map<string, { in: number; out: number }>();
return {
connections: {
active: () => 0,
total: () => 0,
byRoute: () => args.connectionsByRoute,
byIP: () => connectionsByIP,
topIPs: (limit = 10) => Array.from(connectionsByIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, count]) => ({ ip, count })),
domainRequestsByIP: () => args.domainRequestsByIP,
topDomainRequests: () => [],
frontendProtocols: () => emptyProtocolDistribution,
backendProtocols: () => emptyProtocolDistribution,
},
throughput: {
instant: () => ({ in: 0, out: 0 }),
recent: () => ({ in: 0, out: 0 }),
average: () => ({ in: 0, out: 0 }),
custom: () => ({ in: 0, out: 0 }),
history: () => [],
byRoute: () => args.throughputByRoute,
byIP: () => throughputByIP,
},
requests: {
perSecond: () => 0,
perMinute: () => 0,
total: () => args.requestsTotal || 0,
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
},
totals: {
bytesIn: () => 0,
bytesOut: () => 0,
connections: () => 0,
},
backends: {
byBackend: () => args.backendMetrics || new Map<string, any>(),
protocols: () => new Map<string, string>(),
topByErrors: () => [],
detectedProtocols: () => args.protocolCache || [],
},
};
}
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map([
['route-id-only', 4],
]),
throughputByRoute: new Map([
['route-id-only', { in: 1200, out: 2400 }],
]),
domainRequestsByIP: new Map([
['192.0.2.10', new Map([
['alpha.example.com', 3],
['beta.example.com', 1],
])],
]),
requestsTotal: 4,
});
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 3, routeId: 'route-id-only', domain: 'alpha.example.com' },
{ count: 1, routeId: 'route-id-only', domain: 'beta.example.com' },
]),
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,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 10, routeId: 'route-id-only', domain: 'beta.example.com' },
]),
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,
getActiveConnectionSnapshots: () => [],
routeManager: {
getRoutes: () => [],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const aggregate = stats.backends.find((item) => item.id === 'backend:192.0.2.1:443');
const cacheRows = stats.backends.filter((item) => item.id?.startsWith('cache:'));
expect(aggregate!.activeConnections).toEqual(257);
expect(cacheRows.length).toEqual(2);
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
});
tap.test('MetricsManager queues IP intelligence without awaiting enrichment', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['1.1.1.1', 2],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['1.1.1.1', { in: 1500, out: 1000 }],
]),
});
const queuedIps: string[][] = [];
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 4, sourceIp: '8.8.8.8' },
{ count: 2, sourceIp: '1.1.1.1' },
]),
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
listIpIntelligence: async () => [],
},
} as any);
await manager.getNetworkStats();
expect(queuedIps).toHaveLength(1);
expect(queuedIps[0]).toContain('8.8.8.8');
expect(queuedIps[0]).toContain('1.1.1.1');
});
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['8.8.4.4', 3],
['1.1.1.1', 5],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['8.8.4.4', { in: 700, out: 350 }],
['1.1.1.1', { in: 2000, out: 1000 }],
]),
});
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 4, sourceIp: '8.8.8.8' },
{ count: 3, sourceIp: '8.8.4.4' },
{ count: 5, sourceIp: '1.1.1.1' },
]),
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: () => undefined,
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
},
} as any);
const stats = await manager.getNetworkStats();
expect(stats.topASNs).toHaveLength(2);
expect(stats.topASNs[0].asn).toEqual(15169);
expect(stats.topASNs[0].organization).toEqual('Google LLC');
expect(stats.topASNs[0].activeConnections).toEqual(7);
expect(stats.topASNs[0].ipCount).toEqual(2);
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
expect(stats.topASNs[1].asn).toEqual(13335);
expect(stats.topASNs[1].activeConnections).toEqual(5);
});
export default tap.start();
+436
View File
@@ -0,0 +1,436 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createMigrationRunner } from '../ts_migrations/index.js';
function setPath(target: Record<string, any>, path: string, value: unknown): void {
const parts = path.split('.');
let cursor = target;
for (const part of parts.slice(0, -1)) {
cursor[part] = cursor[part] || {};
cursor = cursor[part];
}
cursor[parts[parts.length - 1]] = value;
}
function getPath(target: Record<string, any>, path: string): unknown {
let cursor: any = target;
for (const part of path.split('.')) {
if (cursor === null || cursor === undefined) return undefined;
cursor = cursor[part];
}
return cursor;
}
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
for (const [key, value] of Object.entries(set)) {
setPath(document, key, value);
}
}
function unsetPath(target: Record<string, any>, path: string): void {
const parts = path.split('.');
let cursor: any = target;
for (const part of parts.slice(0, -1)) {
if (cursor?.[part] === undefined) return;
cursor = cursor[part];
}
if (cursor && typeof cursor === 'object') {
delete cursor[parts[parts.length - 1]];
}
}
function applyUnset(document: Record<string, any>, unset: Record<string, unknown>): void {
for (const key of Object.keys(unset)) {
unsetPath(document, key);
}
}
function matchesQuery(document: Record<string, any>, query: Record<string, any>): boolean {
for (const [key, expected] of Object.entries(query)) {
const actual = getPath(document, key);
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
if ('$exists' in expected) {
const exists = actual !== undefined;
if (exists !== Boolean(expected.$exists)) return false;
continue;
}
if ('$type' in expected) {
if (expected.$type === 'string' && typeof actual !== 'string') return false;
continue;
}
if ('$in' in expected) {
if (!Array.isArray(expected.$in) || !expected.$in.includes(actual)) return false;
continue;
}
}
if (actual !== expected) return false;
}
return true;
}
function createFakeCollection(documents: Array<Record<string, any>> = []) {
return {
findOne: async (query: Record<string, any> = {}) => {
const document = documents.find((candidate) => matchesQuery(candidate, query));
return document ? structuredClone(document) : null;
},
find: (query: Record<string, any> = {}) => ({
async *[Symbol.asyncIterator]() {
for (const document of documents) {
if (matchesQuery(document, query)) {
yield structuredClone(document);
}
}
},
}),
insertOne: async (document: Record<string, any>) => {
documents.push(structuredClone(document));
return { insertedId: document._id || document.id };
},
updateMany: async (query: Record<string, any>, update: any) => {
let modifiedCount = 0;
for (const document of documents) {
if (!matchesQuery(document, query)) continue;
applySet(document, update.$set || {});
applyUnset(document, update.$unset || {});
modifiedCount++;
}
return { modifiedCount };
},
updateOne: async (query: Record<string, any>, update: any) => {
const document = documents.find((candidate) => matchesQuery(candidate, query));
if (!document) return { matchedCount: 0, modifiedCount: 0, upsertedCount: 0 };
applySet(document, update.$set || {});
applyUnset(document, update.$unset || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
}
function createFakeDb(
currentVersion: string,
collections: Record<string, Array<Record<string, any>>> = {},
) {
const ledgerDocument = {
nameId: 'smartmigration:smartmigration',
data: {
currentVersion,
steps: {},
lock: { holder: null, acquiredAt: null, expiresAt: null },
checkpoints: {},
},
};
const fakeCollections = new Map(
Object.entries(collections).map(([name, documents]) => [name, createFakeCollection(documents)]),
);
const emptyCollection = createFakeCollection();
const ledgerCollection = {
createIndex: async () => undefined,
findOne: async () => structuredClone(ledgerDocument),
findOneAndUpdate: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return structuredClone(ledgerDocument);
},
updateOne: async (_query: unknown, update: any) => {
applySet(ledgerDocument, update.$set || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
return {
mongoDb: {
collection: (name: string) =>
name === 'SmartdataEasyStore'
? ledgerCollection
: fakeCollections.get(name) || emptyCollection,
},
};
}
tap.test('migration runner applies schema steps through the current target', async () => {
const sourceProfiles: Array<Record<string, any>> = [];
const runner = await createMigrationRunner(
createFakeDb('13.16.0', { SourceProfileDoc: sourceProfiles }),
'13.42.0',
);
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.42.0');
expect(result.stepsApplied).toHaveLength(4);
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('PUBLIC');
});
tap.test('migration runner uses exact SmartData collection names for DNS source renames', async () => {
const domains: Array<Record<string, any>> = [{ _id: 'domain-1', source: 'manual' }];
const records: Array<Record<string, any>> = [{ _id: 'record-1', source: 'manual' }];
const runner = await createMigrationRunner(
createFakeDb('13.1.0', {
DomainDoc: domains,
DnsRecordDoc: records,
}),
'13.8.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(2);
expect(domains[0].source).toEqual('dcrouter');
expect(records[0].source).toEqual('local');
});
tap.test('migration runner rematerializes source-profile-backed route security', async () => {
const profiles: Array<Record<string, any>> = [
{
_id: 'profile-doc-1',
id: 'standard-profile',
name: 'Standard',
security: {
ipAllowList: ['192.168.*', '127.0.0.1'],
maxConnections: 1000,
},
},
];
const routes: Array<Record<string, any>> = [
{
_id: 'route-doc-1',
id: 'route-1',
route: {
name: 'Public service domains',
match: { ports: 443, domains: ['code.foss.global'] },
action: { type: 'forward', targets: [{ host: '192.168.5.247', port: 443 }] },
security: {
ipAllowList: ['192.168.*', '*'],
maxConnections: 1000,
},
},
metadata: {
sourceProfileRef: 'standard-profile',
sourceProfileName: 'Standard',
},
updatedAt: 1,
},
];
const runner = await createMigrationRunner(
createFakeDb('13.40.1', {
SourceProfileDoc: profiles,
RouteDoc: routes,
}),
'13.40.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(routes[0].route.security.ipAllowList.includes('*')).toBeFalse();
expect(routes[0].route.security.ipAllowList).toContain('192.168.*');
expect(routes[0].route.security.maxConnections).toEqual(1000);
expect(routes[0].metadata.lastResolvedAt).toBeTruthy();
});
tap.test('migration runner seeds only missing default source profiles', async () => {
const sourceProfiles: Array<Record<string, any>> = [
{
id: 'public-profile',
name: 'PUBLIC',
description: 'Existing public profile',
security: { ipAllowList: ['*'] },
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.40.2', { SourceProfileDoc: sourceProfiles }),
'13.42.0',
);
const result = await runner.run();
const publicProfiles = sourceProfiles.filter((profile) => profile.name === 'PUBLIC');
expect(result.stepsApplied).toHaveLength(1);
expect(sourceProfiles).toHaveLength(3);
expect(publicProfiles).toHaveLength(1);
expect(publicProfiles[0].security.rateLimit).toBeUndefined();
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
});
tap.test('migration runner converts legacy route access metadata to source bindings', async () => {
const profiles: Array<Record<string, any>> = [
{
_id: 'profile-doc-1',
id: 'standard-profile',
name: 'Standard',
security: { ipAllowList: ['10.0.0.0/8'] },
},
{
_id: 'profile-doc-2',
id: 'public-profile',
name: 'PUBLIC',
security: { ipAllowList: ['*'] },
},
];
const routes: Array<Record<string, any>> = [
{
_id: 'route-doc-1',
id: 'route-1',
route: {
name: 'standard service',
match: { ports: 443, domains: ['onebox.example.com'] },
action: { type: 'forward', targets: [{ host: '10.0.0.2', port: 443 }] },
security: { ipAllowList: ['10.0.0.0/8'], maxConnections: 1000 },
},
metadata: {
sourceProfileRef: 'standard-profile',
sourceProfileName: 'Old Standard Name',
},
updatedAt: 1,
},
{
_id: 'route-doc-2',
id: 'route-2',
route: {
name: 'gitea',
match: { ports: 443, domains: ['code.example.com'] },
action: { type: 'forward', targets: [{ host: '10.0.0.3', port: 3000 }] },
security: { basicAuth: { username: 'user', password: 'pass' } },
},
metadata: {
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'standard-profile' },
{ sourceProfileRef: 'public-profile' },
],
},
},
updatedAt: 1,
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.1', {
SourceProfileDoc: profiles,
RouteDoc: routes,
}),
'13.43.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(routes[0].metadata.sourceBindings).toEqual([
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Old Standard Name' },
]);
expect(routes[0].metadata.sourceProfileRef).toBeUndefined();
expect(routes[0].metadata.sourceProfileName).toBeUndefined();
expect(routes[0].metadata.sourcePolicy).toBeUndefined();
expect(routes[0].route.security).toBeUndefined();
expect(routes[1].metadata.sourceBindings).toEqual([
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Standard' },
{ sourceProfileRef: 'public-profile', sourceProfileName: 'PUBLIC' },
]);
expect(routes[1].metadata.sourcePolicy).toBeUndefined();
expect(routes[1].route.security.basicAuth.username).toEqual('user');
});
tap.test('migration runner backfills RemoteIngress hub settings from legacy config seed', async () => {
const hubSettingsDocs: Array<Record<string, any>> = [
{
_id: 'remote-ingress-settings-1',
settingsId: 'remote-ingress-hub-settings',
performance: undefined,
updatedAt: 1,
updatedBy: '',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.5', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
'13.43.6',
{
remoteIngressHubSettings: {
enabled: true,
tunnelPort: 29443,
hubDomain: '203.0.113.10',
performance: {
profile: 'balanced',
maxStreamsPerEdge: 10000,
},
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(hubSettingsDocs[0].enabled).toEqual(true);
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
expect(hubSettingsDocs[0].hubDomain).toEqual('203.0.113.10');
expect(hubSettingsDocs[0].performance.profile).toEqual('balanced');
expect(hubSettingsDocs[0].performance.maxStreamsPerEdge).toEqual(10000);
expect(hubSettingsDocs[0].updatedAt).not.toEqual(1);
});
tap.test('migration runner backfills RemoteIngress hub settings at current package target', async () => {
const hubSettingsDocs: Array<Record<string, any>> = [
{
_id: 'remote-ingress-settings-current',
settingsId: 'remote-ingress-hub-settings',
updatedAt: 1,
updatedBy: '',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.2', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
'13.43.5',
{
remoteIngressHubSettings: {
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(hubSettingsDocs[0].enabled).toEqual(true);
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
expect(hubSettingsDocs[0].hubDomain).toEqual('ingress.example.com');
});
tap.test('migration runner backfills Email server settings from legacy config seed', async () => {
const emailSettingsDocs: Array<Record<string, any>> = [];
const runner = await createMigrationRunner(
createFakeDb('13.43.2', { EmailServerSettingsDoc: emailSettingsDocs }),
'13.43.5',
{
emailServerSettings: {
enabled: true,
emailConfig: {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
},
emailPortConfig: {
portMapping: { 25: 10025, 587: 10587 },
},
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(emailSettingsDocs).toHaveLength(1);
expect(emailSettingsDocs[0].enabled).toEqual(true);
expect(emailSettingsDocs[0].emailConfig.hostname).toEqual('mail.example.com');
expect(emailSettingsDocs[0].emailPortConfig.portMapping[25]).toEqual(10025);
});
export default tap.start();
+20
View File
@@ -0,0 +1,20 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { getOciContainerConfig } from '../ts_oci_container/index.js';
tap.test('OCI config should accept explicit DNS bind interface', async () => {
const previousValue = process.env.DCROUTER_DNS_BIND_INTERFACE;
process.env.DCROUTER_DNS_BIND_INTERFACE = '192.168.190.3';
try {
const config = getOciContainerConfig();
expect(config.dnsBindInterface).toEqual('192.168.190.3');
} finally {
if (previousValue === undefined) {
delete process.env.DCROUTER_DNS_BIND_INTERFACE;
} else {
process.env.DCROUTER_DNS_BIND_INTERFACE = previousValue;
}
}
});
export default tap.start();
+126
View File
@@ -0,0 +1,126 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { requireOpsAuth } from '../ts/opsserver/helpers/auth.js';
import * as interfaces from '../ts_interfaces/index.js';
type TScope = interfaces.data.TApiTokenScope;
const makeIdentity = (role: string = 'user'): interfaces.data.IIdentity => ({
jwt: `jwt-${role}`,
userId: `${role}-user`,
name: role,
expiresAt: Date.now() + 3600000,
role,
});
const makeOpsServer = (options: {
identityRole?: string | null;
tokenScopes?: TScope[];
tokenPolicy?: interfaces.data.IApiTokenPolicy;
}) => {
const token = {
id: 'token-1',
name: 'test-token',
tokenHash: 'hash',
scopes: options.tokenScopes || [],
policy: options.tokenPolicy,
createdAt: Date.now(),
expiresAt: null,
lastUsedAt: null,
createdBy: 'token-user',
enabled: true,
} as interfaces.data.IStoredApiToken;
return {
adminHandler: {
validateIdentity: async (identityArg?: interfaces.data.IIdentity) => {
if (!identityArg || options.identityRole === null) return null;
return { ...identityArg, role: options.identityRole || identityArg.role || 'user' };
},
},
dcRouterRef: {
apiTokenManager: {
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: TScope) => {
if (storedTokenArg.policy?.role === 'admin') return true;
return storedTokenArg.scopes.includes('*') || storedTokenArg.scopes.includes(scopeArg) || Boolean(storedTokenArg.policy?.scopes?.includes(scopeArg));
},
},
},
} as any;
};
const getErrorText = (errorArg: unknown) => {
return (errorArg as any).errorText || (errorArg as any).text || (errorArg as Error).message;
};
tap.test('requireOpsAuth accepts valid JWT identity for read endpoints', async () => {
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: 'user' }),
{ identity: makeIdentity('user') },
{ scope: 'config:read' },
);
expect(auth.type).toEqual('identity');
expect(auth.userId).toEqual('user-user');
expect(auth.isAdmin).toEqual(false);
});
tap.test('requireOpsAuth rejects non-admin JWT identity for admin identity requirements', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: 'user' }),
{ identity: makeIdentity('user') },
{ scope: 'routes:write', requireAdminIdentity: true },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('admin identity required');
});
tap.test('requireOpsAuth accepts scoped API tokens', async () => {
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
{ apiToken: 'valid-token' },
{ scope: 'logs:read' },
);
expect(auth.type).toEqual('apiToken');
expect(auth.userId).toEqual('token-user');
});
tap.test('requireOpsAuth rejects API tokens without the required scope', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
{ apiToken: 'valid-token' },
{ scope: 'stats:read' },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('insufficient scope');
});
tap.test('requireOpsAuth requires admin policy for sensitive API-token operations', async () => {
let errorText = '';
try {
await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenScopes: ['tokens:manage'] }),
{ apiToken: 'valid-token' },
{ scope: 'tokens:manage', requireAdminToken: true },
);
} catch (error) {
errorText = getErrorText(error);
}
expect(errorText).toEqual('admin API token required');
const auth = await requireOpsAuth(
makeOpsServer({ identityRole: null, tokenPolicy: { role: 'admin' } }),
{ apiToken: 'valid-token' },
{ scope: 'tokens:manage', requireAdminToken: true },
);
expect(auth.isAdmin).toEqual(true);
});
export default tap.start();
+34 -9
View File
@@ -2,14 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
let opsServerPort: number;
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function typedRequestUrl(): string {
return `http://127.0.0.1:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3101,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -19,22 +40,26 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
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 () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getHealthStatus'
);
@@ -50,7 +75,7 @@ tap.test('should respond to health status request', async () => {
tap.test('should respond to server statistics request', async () => {
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getServerStatistics'
);
@@ -67,7 +92,7 @@ tap.test('should respond to server statistics request', async () => {
tap.test('should respond to configuration request', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getConfiguration'
);
@@ -88,7 +113,7 @@ tap.test('should respond to configuration request', async () => {
tap.test('should handle log retrieval request', async () => {
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getRecentLogs'
);
@@ -105,7 +130,7 @@ tap.test('should handle log retrieval request', async () => {
tap.test('should reject unauthenticated requests', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getHealthStatus'
);
+34 -9
View File
@@ -2,14 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
let opsServerPort: number;
const testAdminPassword = 'test-admin-password';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function getTypedRequestUrl(): string {
return `http://localhost:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3103,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -19,23 +40,27 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
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');
});
tap.test('should allow admin to verify identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -50,7 +75,7 @@ tap.test('should allow admin to verify identity', async () => {
tap.test('should reject verify identity without identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -65,7 +90,7 @@ tap.test('should reject verify identity without identity', async () => {
tap.test('should reject verify identity with invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -85,7 +110,7 @@ tap.test('should reject verify identity with invalid JWT', async () => {
tap.test('should reject protected endpoints without auth', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'getHealthStatus'
);
@@ -101,7 +126,7 @@ tap.test('should reject protected endpoints without auth', async () => {
tap.test('should allow authenticated access to protected endpoints', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'getConfiguration'
);
+48 -198
View File
@@ -3,10 +3,6 @@ import { ReferenceResolver } from '../ts/config/classes.reference-resolver.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: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
@@ -54,10 +50,6 @@ function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
} as IRouteConfig;
}
// ============================================================================
// Resolution tests
// ============================================================================
let resolver: ReferenceResolver;
tap.test('should create ReferenceResolver instance', async () => {
@@ -67,79 +59,43 @@ tap.test('should create ReferenceResolver instance', async () => {
tap.test('should list empty profiles and targets initially', async () => {
expect(resolver.listProfiles()).toBeArray();
expect(resolver.listProfiles().length).toEqual(0);
expect(resolver.listProfiles()).toHaveLength(0);
expect(resolver.listTargets()).toBeArray();
expect(resolver.listTargets().length).toEqual(0);
expect(resolver.listTargets()).toHaveLength(0);
});
// ---- Source profile resolution ----
tap.test('should resolve source profile onto a route', async () => {
tap.test('should resolve source binding display names without materializing route security', async () => {
const profile = makeProfile();
injectProfile(resolver, profile);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const route = makeRoute({
security: { ipAllowList: ['127.0.0.1'], maxConnections: 42 },
});
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
};
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeTruthy();
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.sourceProfileName).toEqual('STANDARD');
expect(result.route.security!.ipAllowList).toEqual(['127.0.0.1']);
expect(result.route.security!.maxConnections).toEqual(42);
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should merge inline route security with profile security', async () => {
const route = makeRoute({
security: {
ipAllowList: ['127.0.0.1'],
maxConnections: 5000,
},
});
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
// IP lists are unioned
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!.ipAllowList).toContain('127.0.0.1');
// Inline maxConnections overrides profile
expect(result.route.security!.maxConnections).toEqual(5000);
});
tap.test('should deduplicate IP lists during merge', async () => {
const route = makeRoute({
security: {
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
},
});
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
expect(count).toEqual(1);
});
tap.test('should handle missing profile gracefully', async () => {
tap.test('should keep missing source binding refs fail-closed for compiler validation', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'nonexistent-profile' }],
};
const result = resolver.resolveRoute(route, metadata);
// Route should be unchanged
expect(result.route.security).toBeUndefined();
expect(result.metadata.sourceProfileName).toBeUndefined();
expect(result.metadata.sourceBindings![0].sourceProfileName).toBeUndefined();
});
// ---- Profile inheritance ----
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
tap.test('should resolve source profile inheritance for apply-time compiler use', async () => {
const baseProfile = makeProfile({
id: 'base-profile',
name: 'BASE',
@@ -160,46 +116,12 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
});
injectProfile(resolver, extendedProfile);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
const result = resolver.resolveRoute(route, metadata);
// Should have IPs from both base and extended profiles
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
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.sourceProfileName).toEqual('EXTENDED');
const security = resolver.resolveSourceProfileSecurity('extended-profile')!;
expect(security.ipAllowList).toContain('10.0.0.0/8');
expect(security.ipAllowList).toContain('160.79.104.0/21');
expect(security.maxConnections).toEqual(500);
});
tap.test('should detect circular profile inheritance', async () => {
const profileA = makeProfile({
id: 'circular-a',
name: 'A',
security: { ipAllowList: ['1.1.1.1'] },
extendsProfiles: ['circular-b'],
});
const profileB = makeProfile({
id: 'circular-b',
name: 'B',
security: { ipAllowList: ['2.2.2.2'] },
extendsProfiles: ['circular-a'],
});
injectProfile(resolver, profileA);
injectProfile(resolver, profileB);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
// Should not infinite loop — resolves what it can
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeTruthy();
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
});
// ---- Network target resolution ----
tap.test('should resolve network target onto a route', async () => {
const target = makeTarget();
injectTarget(resolver, target);
@@ -209,86 +131,34 @@ tap.test('should resolve network target onto a route', async () => {
const result = resolver.resolveRoute(route, metadata);
expect(result.route.action.targets).toBeTruthy();
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
expect(result.route.action.targets![0].port).toEqual(443);
expect(result.metadata.networkTargetName).toEqual('INFRA');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should handle missing target gracefully', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
const result = resolver.resolveRoute(route, metadata);
// Route targets should be unchanged (still the placeholder)
expect(result.route.action.targets![0].host).toEqual('placeholder');
expect(result.metadata.networkTargetName).toBeUndefined();
});
// ---- Combined resolution ----
tap.test('should resolve both profile and target simultaneously', async () => {
tap.test('should resolve source bindings and target references together', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
sourceProfileRef: 'profile-1',
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
networkTargetRef: 'target-1',
};
const result = resolver.resolveRoute(route, metadata);
// Security from profile
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.maxConnections).toEqual(1000);
// Target from network target
expect(result.route.security).toBeUndefined();
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
expect(result.route.action.targets![0].port).toEqual(443);
// Both names recorded
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
expect(result.metadata.networkTargetName).toEqual('INFRA');
});
tap.test('should skip resolution when no metadata refs', async () => {
const route = makeRoute({
security: { ipAllowList: ['1.2.3.4'] },
});
const metadata: IRouteMetadata = {};
const result = resolver.resolveRoute(route, metadata);
// Route should be completely unchanged
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
expect(result.route.security!.ipAllowList!.length).toEqual(1);
expect(result.route.action.targets![0].host).toEqual('placeholder');
});
tap.test('should be idempotent — resolving twice gives same result', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
sourceProfileRef: 'profile-1',
networkTargetRef: 'target-1',
};
const first = resolver.resolveRoute(route, metadata);
const second = resolver.resolveRoute(first.route, first.metadata);
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
});
// ---- Lookup helpers ----
tap.test('should find routes by profile ref (sync)', async () => {
tap.test('should find routes by source binding profile ref only', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-a', {
id: 'route-a',
route: makeRoute({ name: 'route-a' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1' },
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
});
storedRoutes.set('route-b', {
id: 'route-b',
@@ -300,37 +170,31 @@ tap.test('should find routes by profile ref (sync)', async () => {
id: 'route-c',
route: makeRoute({ name: 'route-c' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
metadata: {
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
networkTargetRef: 'target-1',
},
});
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
expect(profileRefs.length).toEqual(2);
expect(profileRefs).toHaveLength(2);
expect(profileRefs).toContain('route-a');
expect(profileRefs).toContain('route-c');
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
expect(targetRefs.length).toEqual(2);
expect(targetRefs).toHaveLength(2);
expect(targetRefs).toContain('route-b');
expect(targetRefs).toContain('route-c');
});
tap.test('should get profile usage for a specific profile ID', async () => {
tap.test('should get profile and target usage for specific IDs', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-x', {
id: 'route-x',
route: makeRoute({ name: 'my-route' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1' },
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
});
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
expect(usage.length).toEqual(1);
expect(usage[0].id).toEqual('route-x');
expect(usage[0].routeName).toEqual('my-route');
});
tap.test('should get target usage for a specific target ID', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-y', {
id: 'route-y',
route: makeRoute({ name: 'other-route' }),
@@ -338,34 +202,20 @@ tap.test('should get target usage for a specific target ID', async () => {
metadata: { networkTargetRef: 'target-1' },
});
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
expect(usage.length).toEqual(1);
expect(usage[0].id).toEqual('route-y');
expect(usage[0].routeName).toEqual('other-route');
const profileUsage = resolver.getProfileUsageForId('profile-1', storedRoutes);
expect(profileUsage).toHaveLength(1);
expect(profileUsage[0].routeName).toEqual('my-route');
const targetUsage = resolver.getTargetUsageForId('target-1', storedRoutes);
expect(targetUsage).toHaveLength(1);
expect(targetUsage[0].routeName).toEqual('other-route');
});
// ---- Profile/target getters ----
tap.test('should get profile by name', async () => {
const profile = resolver.getProfileByName('STANDARD');
expect(profile).toBeTruthy();
expect(profile!.id).toEqual('profile-1');
});
tap.test('should get target by name', async () => {
const target = resolver.getTargetByName('INFRA');
expect(target).toBeTruthy();
expect(target!.id).toEqual('target-1');
});
tap.test('should return undefined for nonexistent profile name', async () => {
const profile = resolver.getProfileByName('NONEXISTENT');
expect(profile).toBeUndefined();
});
tap.test('should return undefined for nonexistent target name', async () => {
const target = resolver.getTargetByName('NONEXISTENT');
expect(target).toBeUndefined();
tap.test('should get profiles and targets by name', async () => {
expect(resolver.getProfileByName('STANDARD')!.id).toEqual('profile-1');
expect(resolver.getTargetByName('INFRA')!.id).toEqual('target-1');
expect(resolver.getProfileByName('NONEXISTENT')).toBeUndefined();
expect(resolver.getTargetByName('NONEXISTENT')).toBeUndefined();
});
export default tap.start();
+71
View File
@@ -0,0 +1,71 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { RemoteIngressManager } from '../ts/remoteingress/index.js';
import { RemoteIngressHubSettingsDoc } from '../ts/db/index.js';
tap.test('RemoteIngressManager preserves omitted hub settings on partial update', async () => {
const originalLoad = RemoteIngressHubSettingsDoc.load;
const fakeDoc: any = {
settingsId: 'remote-ingress-hub-settings',
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
performance: {
totalWindowBudgetBytes: 134217728,
},
updatedAt: 1,
updatedBy: 'seed',
save: async () => undefined,
};
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
try {
const manager = new RemoteIngressManager();
const settings = await manager.updateHubSettings({
performance: {
maxStreamsPerEdge: 10000,
},
}, 'test-user');
expect(settings.enabled).toEqual(true);
expect(settings.tunnelPort).toEqual(29443);
expect(settings.hubDomain).toEqual('ingress.example.com');
expect(settings.performance?.maxStreamsPerEdge).toEqual(10000);
} finally {
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
}
});
tap.test('RemoteIngressManager clears optional hub settings explicitly', async () => {
const originalLoad = RemoteIngressHubSettingsDoc.load;
const fakeDoc: any = {
settingsId: 'remote-ingress-hub-settings',
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
performance: {
maxStreamsPerEdge: 10000,
},
updatedAt: 1,
updatedBy: 'seed',
save: async () => undefined,
};
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
try {
const manager = new RemoteIngressManager();
const settings = await manager.updateHubSettings({
hubDomain: null,
performance: null,
}, 'test-user');
expect(settings.enabled).toEqual(true);
expect(settings.tunnelPort).toEqual(29443);
expect(settings.hubDomain).toBeUndefined();
expect(settings.performance).toBeUndefined();
} finally {
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
}
});
export default tap.start();
+200
View File
@@ -0,0 +1,200 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { DcRouterDb, IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../ts/db/index.js';
import { SecurityPolicyManager } from '../ts/security/index.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const rule of await SecurityBlockRuleDoc.findAll()) {
await rule.delete();
}
for (const record of await IpIntelligenceDoc.findAll()) {
await record.delete();
}
for (const event of await SecurityPolicyAuditDoc.findRecent(1000)) {
await event.delete();
}
};
const createIntelligenceResult = (asn: number) => ({
asn,
asnOrg: `ASN ${asn}`,
registrantOrg: null,
registrantCountry: null,
networkRange: null,
networkCidrs: null,
abuseContact: null,
country: null,
countryCode: 'US',
city: null,
latitude: null,
longitude: null,
accuracyRadius: null,
timezone: null,
});
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
await manager.createBlockRule({
type: 'cidr',
value: '203.0.113.0 - 203.0.113.255',
reason: 'test range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['203.0.113.0/24']);
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall.blockedIps).toEqual(['203.0.113.0/24']);
});
tap.test('SecurityPolicyManager compiles intelligence network ranges for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.23';
intelligenceDoc.asn = 64500;
intelligenceDoc.asnOrg = 'Example Network';
intelligenceDoc.networkRange = '198.51.100.0 - 198.51.100.127';
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64500',
reason: 'test asn range',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.0/25']);
});
tap.test('SecurityPolicyManager compiles intelligence CIDR arrays for ASN rules', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = '198.51.100.130';
intelligenceDoc.asn = 64501;
intelligenceDoc.asnOrg = 'Example Split Network';
intelligenceDoc.networkRange = null;
intelligenceDoc.networkCidrs = ['198.51.100.128/25', '198.51.101.0/24'];
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
await manager.createBlockRule({
type: 'asn',
value: 'AS64501',
reason: 'test asn cidr array',
});
const policy = await manager.compilePolicy();
expect(policy.blockedCidrs).toEqual(['198.51.100.128/25', '198.51.101.0/24']);
});
tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
const firewall = await manager.compileRemoteIngressFirewall();
expect(firewall).toEqual({ blockedIps: [] });
});
tap.test('SecurityPolicyManager filters listed IP intelligence records', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
for (const [ipAddress, asn] of [['8.8.8.8', 15169], ['1.1.1.1', 13335]] as const) {
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = ipAddress;
intelligenceDoc.asn = asn;
intelligenceDoc.asnOrg = `ASN ${asn}`;
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
}
const records = await manager.listIpIntelligence({ ipAddresses: ['1.1.1.1'] });
expect(records).toHaveLength(1);
expect(records[0].ipAddress).toEqual('1.1.1.1');
});
tap.test('SecurityPolicyManager force refresh waits for an in-flight background observation', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager({ intelligenceRefreshMs: 0 });
let releaseFirstLookup!: () => void;
let lookupCount = 0;
(manager as any).smartNetwork = {
getIpIntelligence: async () => {
lookupCount++;
if (lookupCount === 1) {
await new Promise<void>((resolve) => { releaseFirstLookup = resolve; });
return createIntelligenceResult(64500);
}
return createIntelligenceResult(64501);
},
stop: async () => {},
};
const backgroundObservation = manager.observeIp('8.8.8.8');
await new Promise((resolve) => setTimeout(resolve, 10));
const forcedRefresh = manager.refreshIpIntelligence('8.8.8.8');
releaseFirstLookup();
const record = await forcedRefresh;
await backgroundObservation;
expect(lookupCount).toEqual(2);
expect(record?.asn).toEqual(64501);
});
tap.test('cleanup security policy test db', async () => {
const dbHandle = await testDbPromise;
await clearTestState();
await dbHandle.cleanup();
});
export default tap.start();
@@ -0,0 +1,31 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { SmartMtaStorageManager } from '../ts/email/index.js';
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test-smartmta-storage');
tap.test('SmartMtaStorageManager persists, lists, and deletes keys', async () => {
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
const storageManager = new SmartMtaStorageManager(tempDir);
await storageManager.set('/email/dkim/example.com/default/metadata', 'metadata');
await storageManager.set('/email/dkim/example.com/default/public.key', 'public');
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toEqual('metadata');
const keys = await storageManager.list('/email/dkim/example.com/');
expect(keys).toEqual([
'/email/dkim/example.com/default/metadata',
'/email/dkim/example.com/default/public.key',
]);
await storageManager.delete('/email/dkim/example.com/default/metadata');
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toBeNull();
});
tap.test('cleanup', async () => {
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
await tap.stopForcefully();
});
export default tap.start();
+296
View File
@@ -0,0 +1,296 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '@push.rocks/smartproxy';
import { Buffer } from 'node:buffer';
import * as http from 'node:http';
import * as net from 'node:net';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function startBackend(
handler: http.RequestListener = (_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
},
): Promise<{ server: http.Server; port: number }> {
const server = http.createServer(handler);
const port = await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : 0);
});
});
return { server, port };
}
async function closeServer(server: http.Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
async function requestHeaders(
port: number,
path: string,
headers?: Record<string, string>,
): Promise<http.IncomingMessage> {
return await new Promise<http.IncomingMessage>((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
request.once('error', reject);
});
}
async function readResponseBody(response: http.IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of response) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
tap.test('SmartProxy route rateLimit returns 429 after threshold', async () => {
const backend = await startBackend();
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
name: 'rate-limit-smoke',
match: {
ports: proxyPort,
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: backend.port }],
},
security: {
rateLimit: {
enabled: true,
maxRequests: 1,
window: 60,
keyBy: 'ip',
errorMessage: 'too many requests',
},
},
},
],
});
try {
await proxy.start();
const firstResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
const secondResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
const firstBody = await firstResponse.text();
const secondBody = await secondResponse.text();
expect(firstResponse.status).toEqual(200);
expect(firstBody).toEqual('ok');
expect(secondResponse.status).toEqual(429);
expect(secondBody).toContain('too many requests');
} finally {
await Promise.allSettled([
proxy.stop(),
closeServer(backend.server),
]);
}
});
tap.test('SmartProxy rateLimit is terminal and does not fall through to a lower priority route', async () => {
const limitedBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('limited');
});
const fallbackBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('fallback');
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'terminal-rate-limit',
name: 'terminal-rate-limit',
priority: 10,
match: { ports: proxyPort, domains: 'limited.local' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
},
security: {
rateLimit: {
enabled: true,
maxRequests: 1,
window: 60,
keyBy: 'ip',
errorMessage: 'limited route exceeded',
},
},
},
{
id: 'lower-priority-fallback',
name: 'lower-priority-fallback',
priority: 0,
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
},
},
],
});
try {
await proxy.start();
const firstResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
const secondResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
const firstBody = await readResponseBody(firstResponse);
const secondBody = await readResponseBody(secondResponse);
expect(firstResponse.statusCode).toEqual(200);
expect(firstBody).toEqual('limited');
expect(secondResponse.statusCode).toEqual(429);
expect(secondBody).toContain('limited route exceeded');
expect(secondBody.includes('fallback')).toBeFalse();
} finally {
await Promise.allSettled([
proxy.stop(),
closeServer(limitedBackend.server),
closeServer(fallbackBackend.server),
]);
}
});
tap.test('SmartProxy route maxConnections returns 429 when concurrent limit is exceeded', async () => {
let firstResponse: http.IncomingMessage | undefined;
let secondResponse: http.IncomingMessage | undefined;
let releaseResponse: (() => void) | undefined;
const releasePromise = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const backend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.flushHeaders();
void releasePromise.then(() => response.end('released'));
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'max-connections-smoke',
name: 'max-connections-smoke',
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: backend.port }],
},
security: {
maxConnections: 1,
},
},
],
});
try {
await proxy.start();
firstResponse = await requestHeaders(proxyPort, '/hold');
secondResponse = await requestHeaders(proxyPort, '/blocked');
expect(firstResponse.statusCode).toEqual(200);
expect(secondResponse.statusCode).toEqual(429);
const secondBody = await readResponseBody(secondResponse);
releaseResponse?.();
expect(await readResponseBody(firstResponse)).toEqual('released');
expect(secondBody.length > 0).toBeTrue();
} finally {
releaseResponse?.();
firstResponse?.destroy();
secondResponse?.destroy();
await Promise.allSettled([
proxy.stop(),
closeServer(backend.server),
]);
}
});
tap.test('SmartProxy maxConnections is terminal and does not fall through to a lower priority route', async () => {
let firstResponse: http.IncomingMessage | undefined;
let secondResponse: http.IncomingMessage | undefined;
let releaseResponse: (() => void) | undefined;
const releasePromise = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const limitedBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.flushHeaders();
void releasePromise.then(() => response.end('limited released'));
});
const fallbackBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('fallback');
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'terminal-max-connections',
name: 'terminal-max-connections',
priority: 10,
match: { ports: proxyPort, domains: 'limited.local' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
},
security: {
maxConnections: 1,
},
},
{
id: 'max-connections-lower-priority-fallback',
name: 'max-connections-lower-priority-fallback',
priority: 0,
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
},
},
],
});
try {
await proxy.start();
firstResponse = await requestHeaders(proxyPort, '/hold', { host: 'limited.local' });
secondResponse = await requestHeaders(proxyPort, '/blocked', { host: 'limited.local' });
const secondBody = await readResponseBody(secondResponse);
releaseResponse?.();
const firstBody = await readResponseBody(firstResponse);
expect(firstResponse.statusCode).toEqual(200);
expect(firstBody).toEqual('limited released');
expect(secondResponse.statusCode).toEqual(429);
expect(secondBody.includes('fallback')).toBeFalse();
} finally {
releaseResponse?.();
firstResponse?.destroy();
secondResponse?.destroy();
await Promise.allSettled([
proxy.stop(),
closeServer(limitedBackend.server),
closeServer(fallbackBackend.server),
]);
}
});
export default tap.start();
+937
View File
@@ -0,0 +1,937 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
import { SourcePolicyCompiler, sourcePolicyLimits } from '../ts/config/classes.source-policy-compiler.js';
import type { ISourceProfile, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
function makeRoute(): IRouteConfig {
return {
id: 'route-1',
name: 'gitea',
priority: 10,
match: { ports: 443, domains: 'code.example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
};
}
function makeProfile(profile: Partial<ISourceProfile> & Pick<ISourceProfile, 'id' | 'name'>): ISourceProfile {
return {
description: '',
security: {},
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
...profile,
};
}
tap.test('source policy compiler expands one route into ordered source variants', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'ai',
name: 'AI Crawlers',
security: {
ipAllowList: ['203.0.113.0/24'],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const metadata: IRouteMetadata = {
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'ai' },
{ sourceProfileRef: 'public' },
],
};
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
expect(variants.length).toEqual(3);
expect(variants[0].name).toEqual('gitea:source:Trusted');
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(variants[0].security?.ipAllowList).toBeUndefined();
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(30);
expect(variants[2].match.clientIp).toBeUndefined();
expect(variants[2].security?.rateLimit?.maxRequests).toEqual(120);
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority! + 1);
});
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const metadata: IRouteMetadata = {
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
onExceeded: { type: '429', errorMessage: 'Slow down' },
},
],
};
const [variant] = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
expect(variant.security?.rateLimit?.maxRequests).toEqual(10);
expect(variant.security?.rateLimit?.errorMessage).toEqual('Slow down');
});
tap.test('source policy compiler forces source-policy rate limits to source IP keys', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'header',
headerName: 'x-forwarded-for',
},
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: {
enabled: true,
maxRequests: 10,
window: 60,
keyBy: 'header',
headerName: 'x-client-id',
},
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: ['/git'],
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
},
],
},
],
},
resolver,
'route-1',
);
expect(variants).toHaveLength(2);
expect(variants[0].security?.rateLimit?.keyBy).toEqual('ip');
expect(variants[0].security?.rateLimit?.headerName).toBeUndefined();
expect(variants[1].security?.rateLimit?.keyBy).toEqual('ip');
expect(variants[1].security?.rateLimit?.headerName).toBeUndefined();
});
tap.test('source policy binding can split Gitea path classes before its fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'ai',
name: 'AI Crawlers',
security: {
ipAllowList: ['203.0.113.0/24'],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'ai',
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: ['/*/*.git/info/refs'],
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
},
{
pathClass: 'normal-html',
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
},
],
},
{ sourceProfileRef: 'public' },
],
},
resolver,
'route-1',
);
expect(variants.length).toEqual(3);
expect(variants[0].name).toEqual('gitea:source:AI Crawlers:path:Git Smart HTTP');
expect(variants[0].match.clientIp).toEqual(['203.0.113.0/24']);
expect(variants[0].match.path).toEqual('/*/*.git/info/refs');
expect(variants[0].security?.rateLimit?.maxRequests).toEqual(600);
expect(variants[1].name).toEqual('gitea:source:AI Crawlers:path:Normal HTML');
expect(variants[1].match.path).toBeUndefined();
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(20);
expect(variants[2].name).toEqual('gitea:source:Public');
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
});
tap.test('source policy compiler uses built-in Gitea path class patterns', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }],
},
],
},
resolver,
'route-1',
);
expect(variants.map((variant) => variant.match.path)).toEqual([
'/*/*.git/info/refs',
'/*/*.git/git-upload-pack',
'/*/*.git/git-receive-pack',
'/*/*.git/info/lfs',
'/*/*.git/info/lfs/*',
undefined,
]);
expect(variants[0].id).toEqual('route-1:source:public:path:git-smart-http:1');
expect(variants[5].id).toEqual('route-1:source:public');
expect(variants[0].priority! > variants[5].priority!).toBeTrue();
});
tap.test('source policy compiler keeps path-specific variants above fallback variants', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [
{
pathClass: 'normal-html',
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
},
{
pathClass: 'git-smart-http',
pathPatterns: ['/*/*.git/info/refs'],
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
},
],
},
],
},
resolver,
'route-1',
);
const fallbackVariant = variants.find((variant) => variant.match.path === undefined)!;
const gitVariant = variants.find((variant) => variant.match.path === '/*/*.git/info/refs')!;
expect(gitVariant.priority! > fallbackVariant.priority!).toBeTrue();
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
});
tap.test('source policy compiler fails closed when wildcard binding shadows later bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{ sourceProfileRef: 'public' },
{ sourceProfileRef: 'trusted' },
],
},
resolver,
'route-1',
);
expect(variants).toEqual([]);
});
tap.test('source policy compiler adds terminal deny fallback for private-only bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [{ sourceProfileRef: 'trusted' }],
},
resolver,
'route-1',
);
expect(variants).toHaveLength(2);
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(variants[1].id).toEqual('route-1:source:deny-fallback');
expect(variants[1].match.clientIp).toBeUndefined();
expect(variants[1].action.type).toEqual('socket-handler');
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > makeRoute().priority!).toBeTrue();
});
tap.test('source policy compiler fails closed when expansion would exceed route variant caps', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const pathPolicies = Array.from({ length: sourcePolicyLimits.maxPathPoliciesPerBinding }, (_policy, policyIndex) => ({
pathClass: 'git-smart-http' as const,
pathPatterns: Array.from(
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy },
(_pattern, patternIndex) => `/heavy-${policyIndex}-${patternIndex}`,
),
}));
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'public', pathPolicies }],
};
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings)).toContain('compiled route variants');
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
});
tap.test('source policy compiler fails closed when configured bindings cannot compile', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: {
ipAllowList: [],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{ sourceProfileRef: 'empty-ai' },
],
},
resolver,
'route-1',
);
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
},
undefined,
'route-1',
);
expect(emptyProfileVariants.length).toEqual(0);
expect(missingResolverVariants.length).toEqual(0);
});
tap.test('source policy compiler keeps generated priorities inside SmartProxy bounds', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const route = makeRoute();
route.priority = 9000;
const variants = SourcePolicyCompiler.compileRoute(
route,
{
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
},
],
},
resolver,
'route-1',
);
expect(variants.length > 0).toBeTrue();
expect(variants.every((variant) => variant.priority! <= 10000 && variant.priority! >= 0)).toBeTrue();
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
});
tap.test('source policy compiler fails closed when route priority lacks variant headroom', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const route = makeRoute();
route.priority = 10000;
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'trusted' }],
};
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings, route)).toContain('priority headroom');
expect(SourcePolicyCompiler.compileRoute(route, metadata, resolver, 'route-1')).toEqual([]);
});
tap.test('RouteConfigManager applies source policy as expanded runtime routes', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'public' },
],
},
});
await manager.applyRoutes();
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0][0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(appliedRoutes[0][1].match.clientIp).toBeUndefined();
expect(appliedRoutes[0][1].security?.rateLimit?.maxRequests).toEqual(120);
});
tap.test('RouteConfigManager does not apply an uncompiled source-policy route', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: {
ipAllowList: [],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
},
});
await manager.applyRoutes();
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(0);
});
tap.test('RouteConfigManager fail-closes managed routes without source bindings', async () => {
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
ownerType: 'gatewayClient',
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
gatewayClientAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
},
});
await manager.applyRoutes();
expect(appliedRoutes).toHaveLength(1);
expect(appliedRoutes[0]).toHaveLength(0);
});
tap.test('RouteConfigManager rejects wildcard source policy bindings before later bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('Wildcard source profile bindings must be last');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
});
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain("Source profile 'missing' not found");
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
});
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: { ipAllowList: [] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain("Source profile 'Empty AI' has no source matches");
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
});
tap.test('RouteConfigManager accepts private-only source bindings without public fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).persistRoute = async () => undefined;
(manager as any).applyRoutes = async () => undefined;
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }],
},
});
expect(result.success).toBeTrue();
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
});
tap.test('RouteConfigManager rejects source policies with broad port range expansion', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
route: {
match: { ports: [{ from: 1, to: 1_000_000_000 }], domains: 'code.example.com' },
} as any,
});
expect(result.success).toBeFalse();
expect(result.message).toContain('compiled route-port variants');
expect(manager.getRoute('route-1')?.route.match.ports).toEqual(443);
});
tap.test('RouteConfigManager rejects negative source-policy maxConnections overrides', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('maxConnections');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].maxConnections).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: {
enabled: true,
maxRequests: 10,
window: 60,
keyBy: 'ip',
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
},
},
],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('rate limit error message');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].rateLimit).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: Array.from(
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
(_item, index) => `/too-many-${index}`,
),
},
],
},
],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('path patterns');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].pathPolicies).toBeUndefined();
});
export default tap.start();
+8 -2
View File
@@ -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,11 +33,15 @@ 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;
});
// ============================================================================
+471
View File
@@ -0,0 +1,471 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
import { TargetProfileManager } from '../ts/config/classes.target-profile-manager.js';
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
(manager as any).forwardingModeOverride = undefined;
(manager as any).vpnServer = { running: true };
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(true);
expect(stopCalls).toEqual(1);
expect(startCalls).toEqual(1);
expect((manager as any).resolvedForwardingMode).toEqual('socket');
});
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
const manager = new VpnManager({ forwardingMode: 'hybrid' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(false);
expect(stopCalls).toEqual(0);
expect(startCalls).toEqual(0);
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
});
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
const dcRouter = new DcRouter({
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
vpnConfig: { enabled: false },
});
let stopCalls = 0;
let setupCalls = 0;
let applyCalls = 0;
const resolverValues: Array<unknown> = [];
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
(dcRouter as any).routeConfigManager = {
setVpnClientAccessResolver: (resolver: unknown) => {
resolverValues.push(resolver);
},
applyRoutes: async () => {
applyCalls++;
},
};
(dcRouter as any).setupVpnServer = async () => {
setupCalls++;
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
};
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
expect(stopCalls).toEqual(1);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(0);
expect(typeof resolverValues.at(-1)).toEqual('function');
await dcRouter.updateVpnConfig({ enabled: false });
expect(stopCalls).toEqual(2);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(1);
expect(resolverValues.at(-1)).toBeUndefined();
expect(dcRouter.vpnManager).toBeUndefined();
});
tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN clients', async () => {
const manager = new RouteConfigManager(() => undefined);
const route = {
name: 'private-route',
vpnOnly: true,
match: { domains: ['private.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: { ipAllowList: ['*'] },
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['*']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
});
tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['client-1'],
);
const route = {
name: 'private-route',
vpnOnly: true,
match: { domains: ['private.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: {
ipAllowList: ['*', '203.0.113.10'],
ipBlockList: ['198.51.100.5'],
},
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['*', '203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
});
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['client-1'],
);
const route = {
name: 'shared-private-route',
match: { domains: ['app.example.com'] },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
security: {
ipAllowList: ['203.0.113.10'],
ipBlockList: ['198.51.100.5'],
},
} as any;
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
});
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'hagen.team VPN access',
domains: ['*.hagen.team'],
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'hagen.team VPN access',
domains: ['*.hagen.team'],
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('*.hagen.team');
expect(accessSpec.domains).toContain('app.hagen.team');
});
tap.test('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager leaves real source-IP enforcement to SmartProxy', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager does not grant routes with wildcard source block', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'blocked-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: {
ipAllowList: ['203.0.113.0/24'],
ipBlockList: ['*'],
},
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual([]);
});
tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'public-route',
match: { domains: 'public.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager grants vpnOnly routes through source-policy profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'vpn-only-route',
vpnOnly: true,
match: { domains: 'private.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'source-reachable-app',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.0/24'] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('app.example.com');
});
tap.test('VpnManager normalizes real remote addresses', async () => {
expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10');
expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1');
expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1');
});
tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => {
const manager = new VpnManager({});
let sourceIpChangeCalls = 0;
(manager as any).config.onClientSourceIpsChanged = () => {
sourceIpChangeCalls++;
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }],
]);
(manager as any).vpnServer = {
listClients: async () => ([
{
clientId: 'runtime-client-1',
registeredClientId: 'client-1',
assignedIp: '10.8.0.2',
transportType: 'wireguard',
},
]),
listWgPeers: async () => ([
{
publicKey: 'wg-public-key',
allowedIps: ['10.8.0.2/32'],
endpoint: '198.51.100.44:61234',
bytesSent: 0,
bytesReceived: 0,
packetsSent: 0,
packetsReceived: 0,
},
]),
};
const changed = await manager.refreshClientSourceIps();
const changedAgain = await manager.refreshClientSourceIps();
expect(changed).toEqual(true);
expect(changedAgain).toEqual(false);
expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44');
expect(sourceIpChangeCalls).toEqual(1);
});
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
const manager = new VpnManager({
serverEndpoint: 'vpn.example.com',
getClientAllowedIPs: async () => ['10.8.0.0/24', '203.0.113.10/32'],
});
(manager as any).vpnServer = {
rotateClientKey: async () => ({
entry: {
clientId: 'client-1',
publicKey: 'noise-public-key',
wgPublicKey: 'wg-public-key',
},
wireguardConfig: '[Interface]\nPrivateKey = old\nAddress = 10.8.0.2/24\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.com:51820\n',
secrets: { noisePrivateKey: 'noise-private-key', wgPrivateKey: 'wg-private-key' },
}),
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', targetProfileIds: ['profile-1'] }],
]);
(manager as any).persistClient = async () => {};
const bundle = await manager.rotateClientKey('client-1');
expect(bundle.wireguardConfig).toContain('AllowedIPs = 10.8.0.0/24, 203.0.113.10/32');
});
export default tap.start()
+229
View File
@@ -0,0 +1,229 @@
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);
});
tap.test('WorkAppMailManager maps shared mail address bindings to WorkApp identities', async () => {
const { dcRouterRef } = createDcRouterStub();
const manager = new WorkAppMailManager(dcRouterRef);
const syncResult = await manager.syncMailAddressBinding({
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.4',
port: 2527,
},
},
}, 'tester');
expect(syncResult.success).toEqual(true);
expect(syncResult.binding?.owner).toEqual({
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
});
expect(syncResult.binding?.inboundTarget?.smtpForward?.host).toEqual('10.0.0.4');
expect(syncResult.binding?.outboundIdentityId?.startsWith('workapp-')).toEqual(true);
const addressBindings = await manager.listMailAddressBindings({
owner: { appInstanceId: 'app-1' },
domain: 'example.com',
});
expect(addressBindings.length).toEqual(1);
expect(addressBindings[0].address).toEqual('hello@example.com');
expect(addressBindings[0].recipientPolicy?.staticRecipients).toEqual(['hello@example.com']);
const workAppBindings = await manager.listWorkAppMailBindings({
gatewayClientId: 'box-1',
});
expect(workAppBindings.length).toEqual(1);
expect(workAppBindings[0].addressBindingIds).toEqual([syncResult.binding!.id]);
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
expect(generatedRoute.action.forward.host).toEqual('10.0.0.4');
const deleteResult = await manager.deleteMailAddressBinding(syncResult.binding!.id, 'tester');
expect(deleteResult.success).toEqual(true);
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
});
export default tap.start();
+837
View File
@@ -0,0 +1,837 @@
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 equivalentScopes: 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(equivalentScopes[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;
for (const [key, value] of Object.entries(patch.route)) {
if (value === null) {
delete (storedRoute.route as any)[key];
}
}
}
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 standardSourceProfile: interfaces.data.ISourceProfile = {
id: 'standard',
name: 'STANDARD',
description: 'Standard test profile',
security: { ipAllowList: ['10.0.0.0/8'] },
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
};
const makeReferenceResolver = () => ({
listProfiles: () => [standardSourceProfile],
});
const setupHandler = (options: {
scopes: TScope[];
policy?: interfaces.data.IApiTokenPolicy;
isAdmin?: boolean;
dcRouterRef?: Record<string, any>;
}) => {
const typedrouter = new plugins.typedrequest.TypedRouter();
const opsServerRef: any = {
typedrouter,
adminHandler: {
validateIdentity: async (identity: interfaces.data.IIdentity) => options.isAdmin
? { ...identity, role: 'admin' }
: identity,
adminIdentityGuard: {
exec: async () => Boolean(options.isAdmin),
},
},
dcRouterRef: {
options: {},
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
referenceResolver: makeReferenceResolver(),
...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: {
dnsScopes: ['example.com'],
http3: { enabled: false },
},
remoteIngressManager: {
getHubSettings: () => ({ enabled: true }),
},
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({
sourceBindings: [{ sourceProfileRef: 'standard', sourceProfileName: 'STANDARD' }],
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',
});
createdRoute.route.security = { ipAllowList: ['*'] };
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');
expect(routeConfig.routes.get('route-1')?.route.security).toBeUndefined();
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');
});
tap.test('WorkHosterHandler exposes shared mail address binding handlers', async () => {
const syncedRequests: Array<{ binding: any; userId: string }> = [];
const deletedRequests: Array<{ id: string; userId: string }> = [];
const binding: plugins.servezoneInterfaces.data.IMailAddressBinding = {
id: 'mail-1',
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
status: 'active',
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.2',
port: 2525,
},
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read', 'gateway-clients:write'],
dcRouterRef: {
options: {},
workAppMailManager: {
listMailAddressBindings: async (filter: any) => filter.owner?.appInstanceId === 'app-1' ? [binding] : [],
syncMailAddressBinding: async (data: any, userId: string) => {
syncedRequests.push({ binding: data, userId });
return { success: true, binding };
},
deleteMailAddressBinding: async (id: string, userId: string) => {
deletedRequests.push({ id, userId });
return { success: true };
},
listWorkAppMailBindings: async () => [{
id: 'workapp-mail-1',
owner: binding.owner as plugins.servezoneInterfaces.data.IMailResourceOwner & { appInstanceId: string },
enabled: true,
status: 'active' as const,
addressBindingIds: [binding.id],
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
}],
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listMailAddressBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.bindings).toEqual([binding]);
const syncResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncedRequests[0].userId).toEqual('token-user');
const workAppListResult = await fireTypedRequest(typedrouter, 'listWorkAppMailBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(workAppListResult.error).toBeUndefined();
expect(workAppListResult.response.bindings[0].addressBindingIds).toEqual(['mail-1']);
const deleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: binding.id,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response.success).toEqual(true);
expect(deletedRequests[0]).toEqual({ id: 'mail-1', userId: 'token-user' });
});
tap.test('WorkHosterHandler scopes shared mail handlers to gateway client token policy', async () => {
const listFilters: any[] = [];
const workAppFilters: any[] = [];
const syncedRequests: Array<{ binding: any; userId: string }> = [];
const deletedRequests: Array<{ id: string; userId: string }> = [];
const binding: plugins.servezoneInterfaces.data.IMailAddressBinding = {
id: 'mail-owned',
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-policy',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
status: 'active',
inboundTarget: {
type: 'smtpForward',
smtpForward: {
host: '10.0.0.2',
port: 2525,
},
},
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
};
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read', 'gateway-clients:write'],
policy: {
role: 'gatewayClient',
gatewayClient: { type: 'onebox', id: 'box-policy' },
allowedRouteTargets: [{ host: '10.0.0.2', ports: [2525] }],
capabilities: { syncRoutes: true },
},
dcRouterRef: {
options: {},
workAppMailManager: {
listMailAddressBindings: async (filter: any) => {
listFilters.push(filter);
return filter.owner?.gatewayClientId === 'box-policy' ? [binding] : [];
},
syncMailAddressBinding: async (data: any, userId: string) => {
syncedRequests.push({ binding: data, userId });
return { success: true, binding: data };
},
deleteMailAddressBinding: async (id: string, userId: string) => {
deletedRequests.push({ id, userId });
return { success: true };
},
listWorkAppMailBindings: async (owner: any) => {
workAppFilters.push(owner);
return owner?.gatewayClientId === 'box-policy' ? [{
id: 'workapp-mail-1',
owner: binding.owner as plugins.servezoneInterfaces.data.IMailResourceOwner & { appInstanceId: string },
enabled: true,
status: 'active' as const,
addressBindingIds: [binding.id],
createdAt: 1,
updatedAt: 1,
createdBy: 'token-user',
}] : [];
},
},
},
});
const listResult = await fireTypedRequest(typedrouter, 'listMailAddressBindings', {
auth: { apiToken: 'valid-token' },
});
expect(listResult.error).toBeUndefined();
expect(listResult.response.bindings).toEqual([binding]);
expect(listFilters[0].owner.gatewayClientId).toEqual('box-policy');
const workAppListResult = await fireTypedRequest(typedrouter, 'listWorkAppMailBindings', {
auth: { apiToken: 'valid-token' },
owner: { appInstanceId: 'app-1' },
});
expect(workAppListResult.error).toBeUndefined();
expect(workAppListResult.response.bindings[0].addressBindingIds).toEqual(['mail-owned']);
expect(workAppFilters[0].gatewayClientId).toEqual('box-policy');
const spoofResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
...binding,
owner: { ...binding.owner, gatewayClientId: 'other-box' },
},
});
expect(spoofResult.error).toBeUndefined();
expect(spoofResult.response.success).toEqual(false);
expect(spoofResult.response.message).toEqual('gateway client token cannot act for this ownership');
const blockedTargetResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
...binding,
inboundTarget: {
type: 'smtpForward',
smtpForward: { host: '10.0.0.9', port: 2525 },
},
},
});
expect(blockedTargetResult.error).toBeUndefined();
expect(blockedTargetResult.response.success).toEqual(false);
expect(blockedTargetResult.response.message).toEqual('mail target is outside token policy: 10.0.0.9:2525');
const syncResult = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding,
});
expect(syncResult.error).toBeUndefined();
expect(syncResult.response.success).toEqual(true);
expect(syncedRequests[0].binding.owner.gatewayClientId).toEqual('box-policy');
const skippedDeleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: 'mail-other',
});
expect(skippedDeleteResult.error).toBeUndefined();
expect(skippedDeleteResult.response.success).toEqual(true);
expect(deletedRequests.length).toEqual(0);
const deleteResult = await fireTypedRequest(typedrouter, 'deleteMailAddressBinding', {
auth: { apiToken: 'valid-token' },
id: binding.id,
});
expect(deleteResult.error).toBeUndefined();
expect(deleteResult.response.success).toEqual(true);
expect(deletedRequests[0]).toEqual({ id: 'mail-owned', userId: 'token-user' });
});
tap.test('WorkHosterHandler rejects shared mail sync without gateway-clients:write', async () => {
const { typedrouter } = setupHandler({
scopes: ['gateway-clients:read'],
dcRouterRef: {
options: {},
workAppMailManager: {
syncMailAddressBinding: async () => ({ success: true }),
},
},
});
const result = await fireTypedRequest(typedrouter, 'syncMailAddressBinding', {
auth: { apiToken: 'valid-token' },
binding: {
owner: {
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
appInstanceId: 'app-1',
},
address: 'hello@example.com',
localPart: 'hello',
domain: 'example.com',
enabled: true,
},
});
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
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.0.8',
version: '14.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+1
View File
@@ -0,0 +1 @@
export * from './manager.acme-config.js';
+108
View File
@@ -0,0 +1,108 @@
import { logger } from '../logger.js';
import { AcmeConfigDoc } from '../db/documents/index.js';
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
/**
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
*
* Lifecycle:
* - `start()` — loads the DB-backed singleton configuration.
* - `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;
public async start(): Promise<void> {
logger.log('info', 'AcmeConfigManager: starting');
const doc = await AcmeConfigDoc.load();
if (!doc) {
logger.log('info', 'AcmeConfigManager: no AcmeConfig in DB — ACME disabled until configured 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
// ==========================================================================
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,
};
}
}
+1486 -419
View File
File diff suppressed because it is too large Load Diff
+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 equivalentScopes: 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(equivalentScopes[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;
+28 -1
View File
@@ -68,11 +68,38 @@ export class DbSeeder {
}
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
{
name: 'TRUSTED NETWORKS',
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
security: {
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
maxConnections: 5000,
},
},
{
name: 'AI CRAWLERS',
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
security: {
ipAllowList: [],
rateLimit: {
enabled: true,
maxRequests: 30,
window: 60,
keyBy: 'ip',
},
},
},
{
name: 'PUBLIC',
description: 'Allow all traffic — no IP restrictions',
description: 'Public fallback source profile with per-IP request limiting',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'ip',
},
},
},
{
+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,
};
}
}
+104 -42
View File
@@ -1,12 +1,13 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
import type {
ISourceProfile,
INetworkTarget,
IRouteMetadata,
IStoredRoute,
IRoute,
IRouteSecurity,
IRouteSourceBinding,
} from '../../ts_interfaces/data/route-management.js';
const MAX_INHERITANCE_DEPTH = 5;
@@ -81,7 +82,7 @@ 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) {
@@ -107,7 +108,7 @@ export class ReferenceResolver {
// If force-deleting with referencing routes, clear refs but keep resolved values
if (affectedIds.length > 0) {
await this.clearProfileRefsOnRoutes(affectedIds);
await this.clearProfileRefsOnRoutes(id, affectedIds, storedRoutes);
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
@@ -131,15 +132,22 @@ export class ReferenceResolver {
return [...this.profiles.values()];
}
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
public resolveSourceProfileSecurity(profileId: string): IRouteSecurity | null {
const resolvedSecurity = this.resolveSourceProfile(profileId);
return resolvedSecurity ? this.cloneSecurityFields(resolvedSecurity) : null;
}
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?.sourceProfileRef;
if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
const refs = this.getSourceProfileRefsFromMetadata(stored.metadata);
for (const ref of refs) {
if (usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
}
return usage;
@@ -147,11 +155,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?.sourceProfileRef === profileId) {
if (this.metadataUsesSourceProfile(stored.metadata, profileId)) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
@@ -214,7 +222,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 +271,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 +288,8 @@ export class ReferenceResolver {
/**
* Resolve references for a single route.
* Materializes source profile and/or network target into the route's fields.
* Resolves source binding display names and/or network target references.
* Source profile security is resolved at apply time by SourcePolicyCompiler.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
@@ -289,33 +298,26 @@ export class ReferenceResolver {
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.sourceProfileRef) {
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
if (resolvedSecurity) {
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.sourceProfileName = profile?.name;
if (resolvedMetadata.sourceBindings?.length) {
const resolvedSourceBindings = this.resolveRouteSourceBindings(resolvedMetadata.sourceBindings);
if (resolvedSourceBindings) {
resolvedMetadata.sourceBindings = resolvedSourceBindings;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
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 +335,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?.sourceProfileRef === profileId)
.filter((doc) => this.metadataUsesSourceProfile(doc.metadata, 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?.sourceProfileRef === profileId) {
if (this.metadataUsesSourceProfile(stored.metadata, 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) {
@@ -370,6 +372,38 @@ export class ReferenceResolver {
// Private: source profile resolution with inheritance
// =========================================================================
private resolveRouteSourceBindings(sourceBindings: IRouteSourceBinding[]): IRouteSourceBinding[] | undefined {
const bindings = sourceBindings
.map((binding) => {
const profile = this.profiles.get(binding.sourceProfileRef);
if (!profile) {
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source binding resolution`);
return binding;
}
return {
...binding,
sourceProfileName: profile.name,
};
})
.filter((binding) => binding.sourceProfileRef);
return bindings.length > 0 ? bindings : undefined;
}
private metadataUsesSourceProfile(metadata: IRouteMetadata | undefined, profileId: string): boolean {
return this.getSourceProfileRefsFromMetadata(metadata).includes(profileId);
}
private getSourceProfileRefsFromMetadata(metadata: IRouteMetadata | undefined): string[] {
const refs = new Set<string>();
for (const binding of metadata?.sourceBindings || []) {
if (binding.sourceProfileRef) {
refs.add(binding.sourceProfileRef);
}
}
return [...refs];
}
private resolveSourceProfile(
profileId: string,
visited: Set<string> = new Set(),
@@ -444,10 +478,15 @@ export class ReferenceResolver {
if (override.authentication !== undefined) merged.authentication = override.authentication;
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
if (override.vpn !== undefined) merged.vpn = override.vpn;
return merged;
}
private cloneSecurityFields(security: IRouteSecurity): IRouteSecurity {
return structuredClone(security);
}
// =========================================================================
// Private: persistence
// =========================================================================
@@ -544,24 +583,47 @@ export class ReferenceResolver {
// Private: ref cleanup on force-delete
// =========================================================================
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
private async clearProfileRefsOnRoutes(
profileId: string,
routeIds: string[],
storedRoutes?: Map<string, IRoute>,
): 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,
sourceProfileRef: undefined,
sourceProfileName: undefined,
};
doc.metadata = this.clearSourceProfileFromMetadata(doc.metadata, profileId);
doc.updatedAt = Date.now();
await doc.save();
}
const storedRoute = storedRoutes?.get(routeId);
if (storedRoute?.metadata) {
storedRoute.metadata = this.clearSourceProfileFromMetadata(storedRoute.metadata, profileId);
storedRoute.updatedAt = Date.now();
}
}
}
private clearSourceProfileFromMetadata(metadata: IRouteMetadata, profileId: string): IRouteMetadata {
const sourceBindings = metadata.sourceBindings?.length
? metadata.sourceBindings.filter((binding) => binding.sourceProfileRef !== profileId)
: undefined;
const nextMetadata: IRouteMetadata = {
...metadata,
sourceBindings: sourceBindings?.length ? sourceBindings : undefined,
};
if (!nextMetadata.sourceBindings && !nextMetadata.networkTargetRef) {
nextMetadata.lastResolvedAt = undefined;
}
return nextMetadata;
}
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,
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
import * as plugins from '../plugins.js';
import {
giteaRoutePathClassLabels,
giteaRoutePathClassPatterns,
routePathClasses,
} from '../../ts_interfaces/data/route-management.js';
import type {
IRoutePathPolicyBinding,
IRouteMetadata,
IRouteSecurity,
IRouteSourceBinding,
} from '../../ts_interfaces/data/route-management.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
const MIN_ROUTE_PRIORITY = 0;
const MAX_ROUTE_PRIORITY = 10000;
const SOURCE_PRIORITY_BAND = 0.0008;
const PATH_PRIORITY_BAND = 0.0001;
export const sourcePolicyLimits = {
maxBindings: 16,
maxPathPoliciesPerBinding: 12,
maxPathPatternsPerPolicy: 64,
maxPathPatternLength: 256,
maxPathPatternWildcards: 8,
maxSourceProfileRefLength: 256,
maxIdLength: 128,
maxExceededMessageLength: 512,
maxCompiledVariantsPerRoute: 512,
} as const;
export class SourcePolicyCompiler {
public static compileRoute(
route: plugins.smartproxy.IRouteConfig,
metadata: IRouteMetadata | undefined,
referenceResolver: ReferenceResolver | undefined,
routeId?: string,
): plugins.smartproxy.IRouteConfig[] {
const bindings = metadata?.sourceBindings || [];
if (bindings.length === 0) {
return [route];
}
if (this.validateSourceBindingsShape(bindings, route)) {
return [];
}
if (!referenceResolver) {
return [];
}
if (this.validateResolvedSourceBindings(bindings, referenceResolver)) {
return [];
}
const compiledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const basePriority = route.priority ?? 0;
let hasAllSourcesBinding = false;
bindings.forEach((binding, index) => {
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
if (!profile || !profileSecurity) {
return;
}
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
if (sourceMatches.length === 0) {
return;
}
if (this.matchesAllSources(sourceMatches)) {
hasAllSourcesBinding = true;
}
const sourcePriority = this.calculateSourcePriority(basePriority, index, bindings.length);
const sourceMatch = this.matchesAllSources(sourceMatches)
? { ...route.match }
: { ...route.match, clientIp: sourceMatches };
const pathPolicies = binding.pathPolicies || [];
if (pathPolicies.length === 0) {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
sourcePriority,
routeId,
sourceIndex: index,
}));
return;
}
let hasSourceFallback = false;
pathPolicies.forEach((pathPolicy, pathIndex) => {
const pathPatterns = this.getPathPatterns(pathPolicy);
if (pathPatterns.length === 0) {
hasSourceFallback = true;
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
pathPolicy,
sourcePriority,
routeId,
sourceIndex: index,
pathIndex,
pathPolicyCount: pathPolicies.length,
}));
return;
}
pathPatterns.forEach((pathPattern, pathPatternIndex) => {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
pathPolicy,
pathPattern,
sourcePriority,
routeId,
sourceIndex: index,
pathIndex,
pathPolicyCount: pathPolicies.length,
pathPatternIndex,
pathPatternCount: pathPatterns.length,
}));
});
});
if (!hasSourceFallback) {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
sourcePriority,
routeId,
sourceIndex: index,
}));
}
});
if (compiledRoutes.length > 0 && !hasAllSourcesBinding) {
compiledRoutes.push(this.buildDenyFallbackRoute(route, basePriority, routeId));
}
return this.applyIntegerPriorities(compiledRoutes, basePriority);
}
public static validateSourceBindingsPayload(sourceBindings?: Partial<IRouteSourceBinding>[]): string | undefined {
if (sourceBindings === undefined) {
return undefined;
}
if (!Array.isArray(sourceBindings)) {
return 'Source bindings must be an array';
}
if (sourceBindings.length === 0) {
return undefined;
}
if (sourceBindings.length > sourcePolicyLimits.maxBindings) {
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
}
const validClasses = new Set<string>(routePathClasses);
for (const binding of sourceBindings) {
if (!binding || typeof binding !== 'object') {
return 'Source binding must be an object';
}
if (typeof binding.sourceProfileRef !== 'string') {
return 'Source binding requires a source profile';
}
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
return `Source binding source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
}
if (binding.sourceProfileRef.trim().length === 0) {
return 'Source binding requires a source profile';
}
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
return `Source binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof binding.maxConnections === 'number' && binding.maxConnections < 0) {
return 'Source policy maxConnections must be non-negative';
}
const bindingRateLimitError = this.validateRateLimitPayload(binding.rateLimit);
if (bindingRateLimitError) {
return bindingRateLimitError;
}
const bindingMessage = binding.onExceeded?.errorMessage;
if (typeof bindingMessage === 'string' && bindingMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
const pathPolicies = binding.pathPolicies;
if (pathPolicies === undefined) {
continue;
}
if (!Array.isArray(pathPolicies)) {
return 'Source policy path policies must be an array';
}
if (pathPolicies.length > sourcePolicyLimits.maxPathPoliciesPerBinding) {
return `Source policy binding exceeds ${sourcePolicyLimits.maxPathPoliciesPerBinding} path policies`;
}
for (const pathPolicy of pathPolicies) {
if (!pathPolicy || typeof pathPolicy !== 'object') {
return 'Source policy path policy must be an object';
}
if (!validClasses.has(pathPolicy.pathClass)) {
return 'Source policy path policy uses an unsupported path class';
}
if (typeof pathPolicy.id === 'string' && pathPolicy.id.length > sourcePolicyLimits.maxIdLength) {
return `Source policy path policy id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof pathPolicy.maxConnections === 'number' && pathPolicy.maxConnections < 0) {
return 'Source policy path policy maxConnections must be non-negative';
}
const pathRateLimitError = this.validateRateLimitPayload(pathPolicy.rateLimit);
if (pathRateLimitError) {
return pathRateLimitError;
}
const pathMessage = pathPolicy.onExceeded?.errorMessage;
if (typeof pathMessage === 'string' && pathMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
const pathPatterns = pathPolicy.pathPatterns;
if (pathPatterns === undefined) {
continue;
}
if (!Array.isArray(pathPatterns)) {
return 'Source policy path patterns must be an array';
}
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
return `Source policy path class exceeds ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
}
for (const pattern of pathPatterns) {
if (typeof pattern !== 'string') {
return 'Source policy path pattern must be a string';
}
if (pattern.length > sourcePolicyLimits.maxPathPatternLength) {
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternLength} characters`;
}
const wildcardCount = pattern.split('*').length - 1;
if (wildcardCount > sourcePolicyLimits.maxPathPatternWildcards) {
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternWildcards} wildcards`;
}
}
}
}
return undefined;
}
private static validateRateLimitPayload(rateLimit: IRouteSecurity['rateLimit'] | undefined): string | undefined {
if (!rateLimit || typeof rateLimit !== 'object') {
return undefined;
}
const rawRateLimit = rateLimit as unknown as Record<string, unknown>;
for (const key of ['maxRequests', 'window'] as const) {
const value = rawRateLimit[key];
if (typeof value === 'string' && value.length > 32) {
return `Source policy rate limit ${key} exceeds 32 characters`;
}
}
if (
typeof rateLimit.errorMessage === 'string'
&& rateLimit.errorMessage.length > sourcePolicyLimits.maxExceededMessageLength
) {
return `Source policy rate limit error message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
return undefined;
}
public static validateSourcePolicyShape(
sourceBindings?: IRouteSourceBinding[],
route?: plugins.smartproxy.IRouteConfig,
): string | undefined {
return this.validateSourceBindingsShape(sourceBindings, route);
}
public static validateSourceBindingsShape(
sourceBindings?: IRouteSourceBinding[],
route?: plugins.smartproxy.IRouteConfig,
): string | undefined {
const payloadError = this.validateSourceBindingsPayload(sourceBindings);
if (payloadError) {
return payloadError;
}
const bindings = sourceBindings || [];
if (bindings.length === 0) {
return undefined;
}
let estimatedCompiledRoutes = 0;
for (const binding of bindings) {
const pathPolicies = binding.pathPolicies || [];
if (pathPolicies.length === 0) {
estimatedCompiledRoutes++;
} else {
let hasSourceFallback = false;
for (const pathPolicy of pathPolicies) {
const pathPatterns = this.getPathPatterns(pathPolicy);
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
return `Source policy path class expands beyond ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
}
if (pathPatterns.length === 0) {
hasSourceFallback = true;
estimatedCompiledRoutes++;
} else {
estimatedCompiledRoutes += pathPatterns.length;
}
}
if (!hasSourceFallback) {
estimatedCompiledRoutes++;
}
}
if (estimatedCompiledRoutes > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route variants`;
}
}
// Private-only source bindings add one terminal deny route to prevent fall-through
// to broader routes with the same host/path/port scope.
estimatedCompiledRoutes++;
const expandedPortCount = route ? this.getExpandedPortCount(route.match?.ports) : 1;
if (estimatedCompiledRoutes * expandedPortCount > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route-port variants`;
}
if (route && typeof route.priority === 'number' && Number.isFinite(route.priority)) {
const integerBasePriority = Math.trunc(this.clampPriority(route.priority));
if (integerBasePriority + estimatedCompiledRoutes > MAX_ROUTE_PRIORITY) {
return `Source policy route priority leaves no priority headroom for ${estimatedCompiledRoutes} compiled variants`;
}
}
return undefined;
}
public static validateResolvedSourcePolicy(
sourceBindings: IRouteSourceBinding[] | undefined,
referenceResolver: ReferenceResolver | undefined,
): string | undefined {
return this.validateResolvedSourceBindings(sourceBindings, referenceResolver);
}
public static validateResolvedSourceBindings(
sourceBindings: IRouteSourceBinding[] | undefined,
referenceResolver: ReferenceResolver | undefined,
): string | undefined {
const bindings = sourceBindings || [];
if (bindings.length === 0) {
return undefined;
}
if (!referenceResolver) {
return 'Source policy requires source profile resolution';
}
for (let index = 0; index < bindings.length; index++) {
const binding = bindings[index];
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
if (!profile) {
return `Source profile '${binding.sourceProfileRef}' not found`;
}
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
if (!profileSecurity) {
return `Source profile '${profile.name}' could not be resolved`;
}
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
if (sourceMatches.length === 0) {
return `Source profile '${profile.name}' has no source matches`;
}
const matchesAllSources = this.matchesAllSources(sourceMatches);
if (matchesAllSources && index < bindings.length - 1) {
return 'Wildcard source profile bindings must be last in source bindings';
}
}
return undefined;
}
private static buildCompiledRoute(options: {
route: plugins.smartproxy.IRouteConfig;
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
profileName: string;
profileSecurity: IRouteSecurity;
binding: IRouteSourceBinding;
pathPolicy?: IRoutePathPolicyBinding;
pathPattern?: string;
sourcePriority: number;
routeId?: string;
sourceIndex: number;
pathIndex?: number;
pathPolicyCount?: number;
pathPatternIndex?: number;
pathPatternCount?: number;
}): plugins.smartproxy.IRouteConfig {
const routeKey = options.route.id || options.routeId || options.route.name || 'route';
const bindingKey = options.binding.id || options.binding.sourceProfileRef || String(options.sourceIndex + 1);
const pathPolicyKey = options.pathPolicy
? options.pathPolicy.id || options.pathPolicy.pathClass
: undefined;
const pathLabel = options.pathPolicy
? giteaRoutePathClassLabels[options.pathPolicy.pathClass]
: undefined;
const pathPatternSuffix = options.pathPatternCount && options.pathPatternCount > 1
? `:${(options.pathPatternIndex || 0) + 1}`
: '';
const pathPriority = options.pathPolicy
? this.calculatePathPriorityOffset(
options.pathPattern,
options.pathIndex || 0,
options.pathPolicyCount || 1,
options.pathPatternIndex || 0,
options.pathPatternCount || 1,
)
: 0;
return {
...options.route,
id: pathPolicyKey
? `${routeKey}:source:${bindingKey}:path:${pathPolicyKey}${pathPatternSuffix}`
: `${routeKey}:source:${bindingKey}`,
name: pathLabel
? `${options.route.name || routeKey}:source:${options.profileName}:path:${pathLabel}${pathPatternSuffix}`
: `${options.route.name || routeKey}:source:${options.profileName}`,
match: options.pathPattern
? { ...options.sourceMatch, path: options.pathPattern }
: { ...options.sourceMatch },
priority: this.clampPriority(options.sourcePriority + pathPriority),
security: this.buildBindingSecurity(
options.route.security,
options.profileSecurity,
options.binding,
options.pathPolicy,
),
};
}
private static buildDenyFallbackRoute(
route: plugins.smartproxy.IRouteConfig,
basePriority: number,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const routeKey = route.id || routeId || route.name || 'route';
return {
...route,
id: `${routeKey}:source:deny-fallback`,
name: `${route.name || routeKey}:source:deny-fallback`,
match: { ...route.match },
priority: this.clampPriority(basePriority - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND),
action: {
type: 'socket-handler',
socketHandler: (socket) => this.denySocket(socket),
},
security: undefined,
};
}
private static denySocket(socket: plugins.net.Socket): void {
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', handleData);
socket.removeListener('error', cleanup);
socket.removeListener('close', cleanup);
};
const handleData = (chunk: string | Uint8Array) => {
cleanup();
if (this.looksLikeHttpRequest(chunk)) {
socket.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 9\r\nConnection: close\r\n\r\nForbidden');
return;
}
socket.destroy();
};
timeout = setTimeout(() => {
cleanup();
socket.destroy();
}, 2000) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
socket.once('data', handleData);
socket.once('error', cleanup);
socket.once('close', cleanup);
}
private static looksLikeHttpRequest(chunk: string | Uint8Array): boolean {
const prefix = typeof chunk === 'string'
? chunk.slice(0, 16)
: String.fromCharCode(...chunk.subarray(0, 16));
return /^(GET|POST|HEAD|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s/.test(prefix)
|| prefix.startsWith('PRI * HTTP/2.0');
}
private static getPathPatterns(pathPolicy: IRoutePathPolicyBinding): string[] {
const patterns: string[] = pathPolicy.pathPatterns?.length
? pathPolicy.pathPatterns
: giteaRoutePathClassPatterns[pathPolicy.pathClass];
return [...new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean))];
}
private static calculatePathPriorityOffset(
pathPattern: string | undefined,
pathIndex: number,
pathPolicyCount: number,
pathPatternIndex: number,
pathPatternCount: number,
): number {
if (!pathPattern) {
return 0;
}
const pathPolicyOffset = ((pathPolicyCount - pathIndex) / (pathPolicyCount + 1))
* (PATH_PRIORITY_BAND * 0.9);
const pathPatternOffset = ((pathPatternCount - pathPatternIndex) / (pathPatternCount + 1))
* (PATH_PRIORITY_BAND * 0.1 / (pathPolicyCount + 1));
return pathPolicyOffset + pathPatternOffset;
}
private static calculateSourcePriority(
basePriority: number,
sourceIndex: number,
sourceCount: number,
): number {
const safeBasePriority = this.clampPriority(
basePriority,
MIN_ROUTE_PRIORITY,
MAX_ROUTE_PRIORITY - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND,
);
const sourceStep = SOURCE_PRIORITY_BAND / (sourceCount + 1);
return safeBasePriority + ((sourceCount - sourceIndex) * sourceStep);
}
private static applyIntegerPriorities(
routes: plugins.smartproxy.IRouteConfig[],
basePriority: number,
): plugins.smartproxy.IRouteConfig[] {
if (routes.length === 0) {
return routes;
}
const priorityOrder = routes
.map((route, originalIndex) => ({
originalIndex,
priority: typeof route.priority === 'number' && Number.isFinite(route.priority)
? route.priority
: basePriority,
}))
.sort((a, b) => (b.priority - a.priority) || (a.originalIndex - b.originalIndex));
const topPriority = Math.trunc(this.clampPriority(
basePriority + routes.length,
MIN_ROUTE_PRIORITY + routes.length,
MAX_ROUTE_PRIORITY,
));
const integerPriorities = new Map<number, number>();
priorityOrder.forEach((entry, index) => {
integerPriorities.set(entry.originalIndex, topPriority - index);
});
return routes.map((route, index) => ({
...route,
priority: integerPriorities.get(index) ?? MIN_ROUTE_PRIORITY,
}));
}
private static clampPriority(
priority: number,
min = MIN_ROUTE_PRIORITY,
max = MAX_ROUTE_PRIORITY,
): number {
if (!Number.isFinite(priority)) {
return min;
}
return Math.min(max, Math.max(min, priority));
}
private static getExpandedPortCount(portRange: plugins.smartproxy.IRouteConfig['match']['ports'] | undefined): number {
if (portRange === undefined) {
return 1;
}
if (typeof portRange === 'number') {
return Number.isFinite(portRange) ? 1 : sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
if (!Array.isArray(portRange)) {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
let count = 0;
for (const portEntry of portRange) {
if (typeof portEntry === 'number') {
if (!Number.isFinite(portEntry)) {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
count++;
} else if (
portEntry
&& typeof portEntry === 'object'
&& Number.isFinite(portEntry.from)
&& Number.isFinite(portEntry.to)
&& portEntry.from <= portEntry.to
) {
count += Math.floor(portEntry.to) - Math.floor(portEntry.from) + 1;
} else {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
if (count > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return count;
}
}
return Math.max(1, count);
}
private static normalizeMaxConnections(value: IRouteSecurity['maxConnections']): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}
private static forceIpRateLimit(
rateLimit: IRouteSecurity['rateLimit'] | undefined,
): IRouteSecurity['rateLimit'] | undefined {
if (!rateLimit) {
return undefined;
}
const { headerName: _headerName, ...rest } = structuredClone(rateLimit as Record<string, any>);
return {
...rest,
keyBy: 'ip',
} as IRouteSecurity['rateLimit'];
}
private static sanitizeSourcePolicySecurity(security: IRouteSecurity): IRouteSecurity {
const sanitized = structuredClone(security);
const maxConnections = this.normalizeMaxConnections(sanitized.maxConnections);
if (maxConnections === undefined) {
delete sanitized.maxConnections;
} else {
sanitized.maxConnections = maxConnections;
}
if (sanitized.rateLimit) {
sanitized.rateLimit = this.forceIpRateLimit(sanitized.rateLimit);
}
return sanitized;
}
private static isEmptySecurity(security: IRouteSecurity): boolean {
return Object.keys(security).length === 0;
}
private static getSourceMatchEntries(security: IRouteSecurity): string[] {
const entries = security.ipAllowList || [];
const normalizedEntries: string[] = [];
for (const entry of entries) {
const rawEntry = typeof entry === 'string' ? entry : entry.ip;
if (typeof rawEntry !== 'string') continue;
const normalizedEntry = rawEntry.trim();
if (normalizedEntry) {
normalizedEntries.push(normalizedEntry);
}
}
return [...new Set(normalizedEntries)];
}
private static matchesAllSources(sourceMatches: string[]): boolean {
return sourceMatches.includes('*')
|| (sourceMatches.includes('0.0.0.0/0') && sourceMatches.includes('::/0'));
}
private static buildBindingSecurity(
routeSecurity: IRouteSecurity | undefined,
profileSecurity: IRouteSecurity,
binding: IRouteSourceBinding,
pathPolicy?: IRoutePathPolicyBinding,
): IRouteSecurity | undefined {
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
const sourceSecurity = this.omitSourceMatchFields(profileSecurity);
if (binding.rateLimit !== undefined) {
sourceSecurity.rateLimit = this.forceIpRateLimit(binding.rateLimit);
}
if (binding.maxConnections !== undefined) {
const maxConnections = this.normalizeMaxConnections(binding.maxConnections);
if (maxConnections === undefined) {
delete sourceSecurity.maxConnections;
} else {
sourceSecurity.maxConnections = maxConnections;
}
}
if (binding.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
sourceSecurity.rateLimit = {
...sourceSecurity.rateLimit,
errorMessage: binding.onExceeded.errorMessage,
};
}
if (pathPolicy?.rateLimit !== undefined) {
sourceSecurity.rateLimit = this.forceIpRateLimit(pathPolicy.rateLimit);
}
if (pathPolicy?.maxConnections !== undefined) {
const maxConnections = this.normalizeMaxConnections(pathPolicy.maxConnections);
if (maxConnections === undefined) {
delete sourceSecurity.maxConnections;
} else {
sourceSecurity.maxConnections = maxConnections;
}
}
if (pathPolicy?.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
sourceSecurity.rateLimit = {
...sourceSecurity.rateLimit,
errorMessage: pathPolicy.onExceeded.errorMessage,
};
}
const mergedSecurity = this.sanitizeSourcePolicySecurity({
...baseSecurity,
...sourceSecurity,
});
return this.isEmptySecurity(mergedSecurity) ? undefined : mergedSecurity;
}
private static omitSourceMatchFields(security: IRouteSecurity): IRouteSecurity {
const { ipAllowList: _ipAllowList, ...controls } = security;
return this.sanitizeSourcePolicySecurity(controls);
}
}
+270 -64
View File
@@ -3,7 +3,9 @@ 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 { IStoredRoute } from '../../ts_interfaces/data/route-management.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
/**
* Manages TargetProfiles (target-side: what can be accessed).
@@ -13,6 +15,10 @@ import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'
export class TargetProfileManager {
private profiles = new Map<string, ITargetProfile>();
constructor(
private getAllRoutes?: () => Map<string, IRoute>,
) {}
// =========================================================================
// Lifecycle
// =========================================================================
@@ -31,18 +37,28 @@ export class TargetProfileManager {
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
createdBy: string;
}): Promise<string> {
// Enforce unique profile names
for (const existing of this.profiles.values()) {
if (existing.name === data.name) {
throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
}
}
const id = plugins.uuid.v4();
const now = Date.now();
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
const profile: ITargetProfile = {
id,
name: data.name,
description: data.description,
domains: data.domains,
targets: data.targets,
routeRefs: data.routeRefs,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
@@ -63,11 +79,22 @@ export class TargetProfileManager {
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 = patch.routeRefs;
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
if (patch.allowRoutesByClientSourceIp !== undefined) {
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
}
profile.updatedAt = Date.now();
await this.persistProfile(profile);
@@ -120,6 +147,29 @@ export class TargetProfileManager {
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()];
}
@@ -139,7 +189,7 @@ export class TargetProfileManager {
// =========================================================================
/**
* For a set of target profile IDs, collect all explicit target host IPs.
* 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.
*/
@@ -149,44 +199,79 @@ export class TargetProfileManager {
const profile = this.profiles.get(profileId);
if (!profile?.targets?.length) continue;
for (const t of profile.targets) {
ips.add(t.host);
ips.add(t.ip);
}
}
return [...ips];
}
// =========================================================================
// Core matching: route → client IPs
// Core matching: route → VPN client grants
// =========================================================================
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns their assigned IPs for injection into ipAllowList.
* Find all enabled VPN clients whose assigned TargetProfile matches the route.
* Returns SmartProxy VPN client allow entries for authenticated metadata checks.
*
* Entries are domain-scoped when a profile matches via specific domains that are
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
* or when profile domains exactly equal the route's domains. Profiles can also opt
* into source-policy routes; SmartProxy evaluates the real source IP per connection.
*/
public getMatchingClientIps(
public getMatchingVpnClients(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
): string[] {
const ips: string[] = [];
allRoutes: Map<string, IRoute> = new Map(),
): TVpnClientAllowEntry[] {
const entries: TVpnClientAllowEntry[] = [];
const routeDomains = this.getRouteDomains(route);
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue;
if (!client.enabled || !client.clientId) continue;
if (!client.targetProfileIds?.length) continue;
// Check if any of the client's profiles match this route
const matches = client.targetProfileIds.some((profileId) => {
const profile = this.profiles.get(profileId);
if (!profile) return false;
return this.routeMatchesProfile(route, routeId, profile);
});
// Collect scoped domains from all matching profiles for this client
let fullAccess = false;
const scopedDomains = new Set<string>();
if (matches) {
ips.push(client.assignedIp);
for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile) continue;
const matchResult = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
if (matchResult === 'full') {
fullAccess = true;
break; // No need to check more profiles
}
if (matchResult !== 'none') {
for (const d of matchResult.domains) scopedDomains.add(d);
}
if (
profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(route)
) {
fullAccess = true;
break;
}
}
if (fullAccess) {
entries.push(client.clientId);
} else if (scopedDomains.size > 0) {
entries.push({ clientId: client.clientId, domains: [...scopedDomains] });
}
}
return ips;
return entries;
}
/**
@@ -195,11 +280,11 @@ export class TargetProfileManager {
*/
public getClientAccessSpec(
targetProfileIds: string[],
allRoutes: IDcRouterRouteConfig[],
storedRoutes: Map<string, IStoredRoute>,
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) {
@@ -216,31 +301,26 @@ export class TargetProfileManager {
// Direct target IP entries
if (profile.targets?.length) {
for (const t of profile.targets) {
targetIps.add(t.host);
targetIps.add(t.ip);
}
}
// Route references: scan constructor routes
for (const route of allRoutes) {
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
const routeDomains = (route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
}
// Route references: scan stored routes
for (const [storedId, stored] of storedRoutes) {
if (!stored.enabled) continue;
if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) {
const routeDomains = (stored.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
// Route references: scan all routes
for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue;
const dcRoute = route.route as IDcRouterRouteConfig;
const routeDomains = this.getRouteDomains(dcRoute);
const profileMatchesRoute = this.routeMatchesProfile(
dcRoute,
routeId,
profile,
routeNameIndex,
);
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(dcRoute);
if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
@@ -257,33 +337,80 @@ export class TargetProfileManager {
// =========================================================================
/**
* Check if a route matches a profile. A profile matches if ANY condition is true:
* 1. Profile's routeRefs contains the route's name or stored route id
* 2. Profile's domains overlaps with route.match.domains (wildcard matching)
* 3. Profile's targets overlaps with route.action.targets (host + port match)
* Check if a route matches a profile (boolean convenience wrapper).
*/
private routeMatchesProfile(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
routeNameIndex: Map<string, string[]>,
): boolean {
// 1. Route reference match
if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return true;
if (route.name && profile.routeRefs.includes(route.name)) return true;
}
const routeDomains = this.getRouteDomains(route);
const result = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
return result !== 'none';
}
// 2. Domain match
if (profile.domains?.length) {
const routeDomains: string[] = (route.match as any)?.domains || [];
for (const profileDomain of profile.domains) {
for (const routeDomain of routeDomains) {
if (this.domainMatchesPattern(routeDomain, profileDomain)) return true;
/**
* 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';
}
}
}
// 3. Target match (host + port)
// 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)) {
@@ -291,15 +418,15 @@ export class TargetProfileManager {
for (const routeTarget of routeTargets) {
const routeHost = routeTarget.host;
const routePort = routeTarget.port;
if (routeHost === profileTarget.host && routePort === profileTarget.port) {
return true;
if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
return 'full';
}
}
}
}
}
return false;
return 'none';
}
/**
@@ -316,6 +443,82 @@ export class TargetProfileManager {
return false;
}
private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
const security = (route as any).security;
const blockEntries = Array.isArray(security?.ipBlockList)
? security.ipBlockList
: security?.ipBlockList
? [security.ipBlockList]
: [];
return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
}
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
const domains = (route.match as any)?.domains;
if (!domains) return [];
return Array.isArray(domains) ? domains : [domains];
}
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
}
private normalizeRouteRefsAgainstRoutes(
routeRefs: string[] | undefined,
allRoutes: Map<string, IRoute>,
mode: 'strict' | 'bestEffort',
): string[] | undefined {
if (!routeRefs?.length) return undefined;
if (!allRoutes.size) return [...new Set(routeRefs)];
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
const normalizedRefs = new Set<string>();
for (const routeRef of routeRefs) {
if (allRoutes.has(routeRef)) {
normalizedRefs.add(routeRef);
continue;
}
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
if (matchingRouteIds.length === 1) {
normalizedRefs.add(matchingRouteIds[0]);
continue;
}
if (mode === 'bestEffort') {
normalizedRefs.add(routeRef);
continue;
}
if (matchingRouteIds.length > 1) {
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
}
throw new Error(`Route reference '${routeRef}' not found`);
}
return [...normalizedRefs];
}
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
const routeNameIndex = new Map<string, string[]>();
for (const [routeId, route] of allRoutes) {
const routeName = route.route.name;
if (!routeName) continue;
const matchingRouteIds = routeNameIndex.get(routeName) || [];
matchingRouteIds.push(routeId);
routeNameIndex.set(routeName, matchingRouteIds);
}
return routeNameIndex;
}
private sameStringArray(left?: string[], right?: string[]): boolean {
if (!left?.length && !right?.length) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
// =========================================================================
// Private: persistence
// =========================================================================
@@ -331,6 +534,7 @@ export class TargetProfileManager {
domains: doc.domains,
targets: doc.targets,
routeRefs: doc.routeRefs,
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
@@ -350,6 +554,7 @@ export class TargetProfileManager {
existingDoc.domains = profile.domains;
existingDoc.targets = profile.targets;
existingDoc.routeRefs = profile.routeRefs;
existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
@@ -360,6 +565,7 @@ export class TargetProfileManager {
doc.domains = profile.domains;
doc.targets = profile.targets;
doc.routeRefs = profile.routeRefs;
doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
+462
View File
@@ -0,0 +1,462 @@
import * as plugins from '../plugins.js';
import type { IHttpRedirectInfo } from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect';
const REDIRECT_STATUS_CODE = 301;
const REDIRECT_PRIORITY = 0;
const REDIRECT_TARGET_TEMPLATE = 'https://{domain}{path}';
const REDIRECT_INITIAL_DATA_TIMEOUT_MS = 10_000;
interface IRedirectCandidate {
key: string;
id: string;
domainPattern: string;
pathPattern?: string;
sourceRouteNames: Set<string>;
sourceRouteIds: Set<string>;
remoteIngress?: IRouteRemoteIngress;
}
interface IRedirectConflict {
routeName: string;
covers: boolean;
}
export interface IHttpRedirectDerivationResult {
redirects: IHttpRedirectInfo[];
runtimeRoutes: IDcRouterRouteConfig[];
}
export function deriveHttpRedirectConfiguration(
routes: plugins.smartproxy.IRouteConfig[],
): IHttpRedirectDerivationResult {
const candidates = collectRedirectCandidates(routes);
const httpRoutes = routes.filter((route) => isExplicitHttpRoute(route));
const redirects: IHttpRedirectInfo[] = [];
const runtimeRoutes: IDcRouterRouteConfig[] = [];
for (const candidate of candidates) {
const conflict = findHttpConflict(candidate, httpRoutes);
const redirectInfo: IHttpRedirectInfo = {
id: candidate.id,
status: conflict ? (conflict.covers ? 'covered' : 'skipped') : 'active',
domainPattern: candidate.domainPattern,
pathPattern: candidate.pathPattern,
fromTemplate: 'http://{domain}{path}',
toTemplate: REDIRECT_TARGET_TEMPLATE,
statusCode: REDIRECT_STATUS_CODE,
priority: REDIRECT_PRIORITY,
sourceRouteNames: [...candidate.sourceRouteNames].sort(),
sourceRouteIds: [...candidate.sourceRouteIds].sort(),
coveredByRouteNames: conflict ? [conflict.routeName] : [],
remoteIngress: Boolean(candidate.remoteIngress?.enabled),
notes: conflict
? conflict.covers
? 'An explicit HTTP route already covers this redirect scope.'
: 'Skipped because an explicit HTTP route overlaps this redirect scope.'
: undefined,
};
redirects.push(redirectInfo);
if (redirectInfo.status === 'active') {
runtimeRoutes.push(buildRuntimeRedirectRoute(candidate));
}
}
return { redirects, runtimeRoutes };
}
export function deriveHttpRedirects(
routes: plugins.smartproxy.IRouteConfig[],
): IHttpRedirectInfo[] {
return deriveHttpRedirectConfiguration(routes).redirects;
}
export function buildHttpRedirectRuntimeRoutes(
routes: plugins.smartproxy.IRouteConfig[],
): IDcRouterRouteConfig[] {
return deriveHttpRedirectConfiguration(routes).runtimeRoutes;
}
function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): IRedirectCandidate[] {
const candidates = new Map<string, IRedirectCandidate>();
for (const route of routes) {
if (!isHttpsRedirectSource(route)) {
continue;
}
for (const domainPattern of getDomainPatterns(route)) {
const key = createRedirectKey(domainPattern, route.match.path);
const existing = candidates.get(key);
if (existing) {
existing.sourceRouteNames.add(getRouteDisplayName(route));
if (route.id) existing.sourceRouteIds.add(route.id);
existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress);
continue;
}
const id = createRedirectRouteName(domainPattern, route.match.path);
candidates.set(key, {
key,
id,
domainPattern,
pathPattern: route.match.path,
sourceRouteNames: new Set([getRouteDisplayName(route)]),
sourceRouteIds: new Set(route.id ? [route.id] : []),
remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress),
});
}
}
return [...candidates.values()].sort((a, b) => a.id.localeCompare(b.id));
}
function isHttpsRedirectSource(route: plugins.smartproxy.IRouteConfig): boolean {
if (isGeneratedRedirectRoute(route)) return false;
if (route.enabled === false) return false;
if (route.action.type !== 'forward') return false;
if (!route.match.ports) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
if (!route.action.tls) return false;
if (!route.match.domains) return false;
if (route.match.transport === 'udp') return false;
if (route.match.protocol && route.match.protocol !== 'http') return false;
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) return false;
return true;
}
function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean {
if (isGeneratedRedirectRoute(route)) return false;
if (route.enabled === false) return false;
if (!route.match.ports) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 80)) return false;
if (route.match.transport === 'udp') return false;
return true;
}
function findHttpConflict(
candidate: IRedirectCandidate,
httpRoutes: plugins.smartproxy.IRouteConfig[],
): IRedirectConflict | undefined {
for (const route of httpRoutes) {
if (!httpRouteOverlapsCandidate(route, candidate)) {
continue;
}
return {
routeName: getRouteDisplayName(route),
covers: httpRouteCoversCandidate(route, candidate),
};
}
return undefined;
}
function httpRouteOverlapsCandidate(
route: plugins.smartproxy.IRouteConfig,
candidate: IRedirectCandidate,
): boolean {
return routeDomainOverlapsCandidate(route, candidate.domainPattern)
&& pathOverlaps(route.match.path, candidate.pathPattern);
}
function httpRouteCoversCandidate(
route: plugins.smartproxy.IRouteConfig,
candidate: IRedirectCandidate,
): boolean {
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) {
return false;
}
return routeDomainCoversCandidate(route, candidate.domainPattern)
&& pathCovers(route.match.path, candidate.pathPattern);
}
function routeDomainOverlapsCandidate(
route: plugins.smartproxy.IRouteConfig,
candidatePattern: string,
): boolean {
const routePatterns = getDomainPatterns(route);
if (routePatterns.length === 0) {
return true;
}
return routePatterns.some((pattern) => domainPatternsOverlap(pattern, candidatePattern));
}
function routeDomainCoversCandidate(
route: plugins.smartproxy.IRouteConfig,
candidatePattern: string,
): boolean {
const routePatterns = getDomainPatterns(route);
if (routePatterns.length === 0) {
return true;
}
return routePatterns.some((pattern) => domainPatternCovers(pattern, candidatePattern));
}
function getDomainPatterns(route: plugins.smartproxy.IRouteConfig): string[] {
if (!route.match.domains) return [];
return Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
}
function normalizePattern(pattern: string): string {
return pattern.trim().toLowerCase().replace(/\.$/, '');
}
function domainPatternCovers(coverPattern: string, candidatePattern: string): boolean {
const cover = normalizePattern(coverPattern);
const candidate = normalizePattern(candidatePattern);
if (cover === candidate) return true;
if (!candidate.includes('*')) return domainPatternMatchesHostname(cover, candidate);
const coverSuffix = getLeadingWildcardSuffix(cover);
const candidateSuffix = getLeadingWildcardSuffix(candidate);
if (coverSuffix && candidateSuffix) {
return candidateSuffix.endsWith(coverSuffix);
}
return false;
}
function domainPatternsOverlap(firstPattern: string, secondPattern: string): boolean {
const first = normalizePattern(firstPattern);
const second = normalizePattern(secondPattern);
if (first === second) return true;
if (!first.includes('*')) return domainPatternMatchesHostname(second, first);
if (!second.includes('*')) return domainPatternMatchesHostname(first, second);
const firstSuffix = getLeadingWildcardSuffix(first);
const secondSuffix = getLeadingWildcardSuffix(second);
if (firstSuffix && secondSuffix) {
return firstSuffix.endsWith(secondSuffix) || secondSuffix.endsWith(firstSuffix);
}
return false;
}
function domainPatternMatchesHostname(pattern: string, hostname: string): boolean {
const regex = wildcardPatternToRegex(normalizePattern(pattern));
return regex.test(normalizePattern(hostname));
}
function wildcardPatternToRegex(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`, 'i');
}
function getLeadingWildcardSuffix(pattern: string): string | undefined {
if (!pattern.startsWith('*')) return undefined;
if (pattern.slice(1).includes('*')) return undefined;
return pattern.slice(1);
}
function pathCovers(coverPath: string | undefined, candidatePath: string | undefined): boolean {
if (!coverPath) return true;
if (!candidatePath) return false;
if (coverPath === candidatePath) return true;
if (!coverPath.includes('*')) return false;
const coverPrefix = coverPath.split('*')[0];
if (!candidatePath.includes('*')) return candidatePath.startsWith(coverPrefix);
const candidatePrefix = candidatePath.split('*')[0];
return candidatePrefix.startsWith(coverPrefix);
}
function pathOverlaps(firstPath: string | undefined, secondPath: string | undefined): boolean {
if (!firstPath || !secondPath) return true;
if (firstPath === secondPath) return true;
const firstPrefix = firstPath.split('*')[0];
const secondPrefix = secondPath.split('*')[0];
return firstPrefix.startsWith(secondPrefix) || secondPrefix.startsWith(firstPrefix);
}
function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRouteConfig {
return {
id: candidate.id,
name: candidate.id,
description: 'Generated HTTP to HTTPS redirect',
priority: REDIRECT_PRIORITY,
tags: ['system', 'redirect', 'auto'],
match: {
ports: 80,
domains: candidate.domainPattern,
...(candidate.pathPattern ? { path: candidate.pathPattern } : {}),
},
action: {
type: 'socket-handler',
socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE),
},
...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}),
};
}
function mergeRemoteIngress(
current: IRouteRemoteIngress | undefined,
next: IRouteRemoteIngress | undefined,
): IRouteRemoteIngress | undefined {
if (!next?.enabled) return current;
if (!current?.enabled) {
return {
enabled: true,
...(next.edgeFilter?.length ? { edgeFilter: [...next.edgeFilter] } : {}),
};
}
const currentFilter = current.edgeFilter || [];
const nextFilter = next.edgeFilter || [];
if (currentFilter.length === 0 || nextFilter.length === 0) {
return { enabled: true };
}
return {
enabled: true,
edgeFilter: [...new Set([...currentFilter, ...nextFilter])].sort(),
};
}
function createRedirectKey(domainPattern: string, pathPattern?: string): string {
return `${normalizePattern(domainPattern)}|${pathPattern || ''}`;
}
function createRedirectRouteName(domainPattern: string, pathPattern?: string): string {
const key = createRedirectKey(domainPattern, pathPattern);
const slug = key
.replace(/\*/g, 'wildcard')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'route';
const hash = plugins.crypto.createHash('sha1').update(key).digest('hex').slice(0, 8);
return `${AUTO_REDIRECT_ROUTE_PREFIX}-${slug}-${hash}`;
}
function getRouteDisplayName(route: plugins.smartproxy.IRouteConfig): string {
return route.name || route.id || 'unnamed-route';
}
function isGeneratedRedirectRoute(route: plugins.smartproxy.IRouteConfig): boolean {
return Boolean(route.name?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX) || route.id?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX));
}
function createHttpRedirectHandler(
locationTemplate: string,
statusCode: number,
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
return (socket, context) => {
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', handleData);
socket.removeListener('error', cleanup);
socket.removeListener('close', cleanup);
};
const handleData = (data: string | Uint8Array) => {
cleanup();
const request = parseHttpRequest(data);
if (!request) {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
return;
}
const domain = normalizeHostHeader(request.headers.host) || context.domain || 'localhost';
const finalLocation = locationTemplate
.replace('{domain}', domain)
.replace('{port}', String(context.port))
.replace('{path}', request.path || '/')
.replace('{clientIp}', context.clientIp);
const message = `Redirecting to ${finalLocation}`;
const response = [
`HTTP/1.1 ${statusCode} ${getHttpStatusText(statusCode)}`,
`Location: ${finalLocation}`,
'Content-Type: text/plain',
`Content-Length: ${message.length}`,
'Connection: close',
'',
message,
].join('\r\n');
socket.end(response);
};
const timeout = setTimeout(() => {
cleanup();
socket.end('HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n');
}, REDIRECT_INITIAL_DATA_TIMEOUT_MS) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
socket.once('data', handleData);
socket.once('error', cleanup);
socket.once('close', cleanup);
};
}
function parseHttpRequest(data: string | Uint8Array): {
method: string;
path: string;
headers: Record<string, string>;
} | undefined {
const requestText = typeof data === 'string' ? data : new TextDecoder().decode(data);
const headerEnd = requestText.indexOf('\r\n\r\n');
const headerText = headerEnd >= 0 ? requestText.slice(0, headerEnd) : requestText;
const lines = headerText.split('\r\n');
const [method, rawPath] = (lines[0] || '').split(' ');
if (!method || !rawPath) return undefined;
const headers: Record<string, string> = {};
for (const line of lines.slice(1)) {
const colonIndex = line.indexOf(':');
if (colonIndex <= 0) continue;
const key = line.slice(0, colonIndex).trim().toLowerCase();
const value = line.slice(colonIndex + 1).trim();
headers[key] = value;
}
return {
method,
path: normalizeRequestPath(rawPath),
headers,
};
}
function normalizeRequestPath(rawPath: string): string {
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
try {
const url = new URL(rawPath);
return `${url.pathname}${url.search}` || '/';
} catch {
return '/';
}
}
return rawPath.startsWith('/') ? rawPath : '/';
}
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
if (!hostHeader) return undefined;
const host = hostHeader.split(',')[0].trim();
if (!host || /[\s\x00-\x1f\x7f]/.test(host)) return undefined;
if (host.startsWith('[')) {
const bracketIndex = host.indexOf(']');
return bracketIndex > 0 ? host.slice(0, bracketIndex + 1) : undefined;
}
return host.replace(/:(80|443)$/, '');
}
function getHttpStatusText(statusCode: number): string {
switch (statusCode) {
case 301:
return 'Moved Permanently';
case 302:
return 'Found';
case 307:
return 'Temporary Redirect';
case 308:
return 'Permanent Redirect';
default:
return 'Redirect';
}
}
+4 -1
View File
@@ -2,6 +2,9 @@
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 { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
export * from './helpers.http-redirects.js';
export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
-111
View File
@@ -1,111 +0,0 @@
import * as plugins from '../plugins.js';
/**
* Base class for all cached documents with TTL support
*
* Extends smartdata's SmartDataDbDoc to add:
* - Automatic timestamps (createdAt, lastAccessedAt)
* - TTL/expiration support (expiresAt)
* - Helper methods for TTL management
*
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
* since decorators on abstract classes don't propagate correctly.
*/
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
/**
* Timestamp when the document was created
* NOTE: Subclasses must add @svDb() decorator
*/
public createdAt: Date = new Date();
/**
* Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator
*/
public expiresAt!: Date;
/**
* Timestamp of last access (for LRU-style eviction if needed)
* NOTE: Subclasses must add @svDb() decorator
*/
public lastAccessedAt: Date = new Date();
/**
* Set the TTL (time to live) for this document
* @param ttlMs Time to live in milliseconds
*/
public setTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set TTL using days
* @param days Number of days until expiration
*/
public setTTLDays(days: number): void {
this.setTTL(days * 24 * 60 * 60 * 1000);
}
/**
* Set TTL using hours
* @param hours Number of hours until expiration
*/
public setTTLHours(hours: number): void {
this.setTTL(hours * 60 * 60 * 1000);
}
/**
* Check if this document has expired
*/
public isExpired(): boolean {
if (!this.expiresAt) {
return false; // No expiration set
}
return new Date() > this.expiresAt;
}
/**
* Update the lastAccessedAt timestamp
*/
public touch(): void {
this.lastAccessedAt = new Date();
}
/**
* Get remaining TTL in milliseconds
* Returns 0 if expired, -1 if no expiration set
*/
public getRemainingTTL(): number {
if (!this.expiresAt) {
return -1;
}
const remaining = this.expiresAt.getTime() - Date.now();
return remaining > 0 ? remaining : 0;
}
/**
* Extend the TTL by the specified milliseconds from now
* @param ttlMs Additional time to live in milliseconds
*/
public extendTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set the document to never expire (100 years in the future)
*/
public setNeverExpires(): void {
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
}
}
/**
* TTL constants in milliseconds
*/
export const TTL = {
HOURS_1: 1 * 60 * 60 * 1000,
HOURS_24: 24 * 60 * 60 * 1000,
DAYS_7: 7 * 24 * 60 * 60 * 1000,
DAYS_30: 30 * 24 * 60 * 60 * 1000,
DAYS_90: 90 * 24 * 60 * 60 * 1000,
} as const;
@@ -0,0 +1,47 @@
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.
*
* 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;
+3 -12
View File
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const TTL = plugins.smartdata.smartdataTtlValues;
/**
* Email status in the cache
*/
@@ -19,17 +20,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
* and maintaining email history for the configured TTL period.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<CachedEmail> {
/**
* Unique identifier for this email
*/
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const TTL = plugins.smartdata.smartdataTtlValues;
/**
* Helper to get the smartdata database instance
*/
@@ -29,17 +30,7 @@ export interface IIPReputationData {
* external API calls. Default TTL is 24 hours.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
export class CachedIPReputation extends plugins.smartdata.SmartdataCachedDocument<CachedIPReputation> {
/**
* IP address (unique identifier)
*/
@@ -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,40 @@
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IEmailPortConfig } from '../../../ts_interfaces/data/email-settings.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class EmailServerSettingsDoc extends plugins.smartdata.SmartDataDbDoc<EmailServerSettingsDoc, EmailServerSettingsDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public settingsId: string = 'email-server-settings';
@plugins.smartdata.svDb()
public enabled: boolean = false;
@plugins.smartdata.svDb()
public emailConfig?: IUnifiedEmailServerOptions;
@plugins.smartdata.svDb()
public emailPortConfig?: IEmailPortConfig;
@plugins.smartdata.svDb()
public updatedAt: number = 0;
@plugins.smartdata.svDb()
public updatedBy: string = '';
constructor() {
super();
}
public static async load(): Promise<EmailServerSettingsDoc | null> {
return await EmailServerSettingsDoc.getInstance({ settingsId: 'email-server-settings' });
}
public static async findAll(): Promise<EmailServerSettingsDoc[]> {
return await EmailServerSettingsDoc.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,5 +1,6 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRemoteIngressPerformanceConfig } from '../../../ts_interfaces/data/remoteingress.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@@ -27,6 +28,9 @@ export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<Remot
@plugins.smartdata.svDb()
public autoDerivePorts!: boolean;
@plugins.smartdata.svDb()
public performance?: IRemoteIngressPerformanceConfig;
@plugins.smartdata.svDb()
public tags!: string[];
@@ -0,0 +1,38 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRemoteIngressPerformanceConfig } from '../../../ts_interfaces/data/remoteingress.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RemoteIngressHubSettingsDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressHubSettingsDoc, RemoteIngressHubSettingsDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public settingsId: string = 'remote-ingress-hub-settings';
@plugins.smartdata.svDb()
public enabled?: boolean;
@plugins.smartdata.svDb()
public tunnelPort?: number;
@plugins.smartdata.svDb()
public hubDomain?: string;
@plugins.smartdata.svDb()
public performance?: IRemoteIngressPerformanceConfig;
@plugins.smartdata.svDb()
public updatedAt: number = 0;
@plugins.smartdata.svDb()
public updatedBy: string = '';
constructor() {
super();
}
public static async load(): Promise<RemoteIngressHubSettingsDoc | null> {
return await RemoteIngressHubSettingsDoc.getInstance({ settingsId: 'remote-ingress-hub-settings' });
}
}
@@ -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);
}
}
@@ -39,10 +39,6 @@ export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourcePro
return await SourceProfileDoc.getInstance({ id });
}
public static async findByName(name: string): Promise<SourceProfileDoc | null> {
return await SourceProfileDoc.getInstance({ name });
}
public static async findAll(): Promise<SourceProfileDoc[]> {
return await SourceProfileDoc.getInstances({});
}
@@ -1,43 +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';
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.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!: 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 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({});
}
}
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
@plugins.smartdata.svDb()
public routeRefs?: string[];
@plugins.smartdata.svDb()
public allowRoutesByClientSourceIp?: boolean;
@plugins.smartdata.svDb()
public createdAt!: number;
@@ -42,10 +45,6 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
return await TargetProfileDoc.getInstance({ id });
}
public static async findByName(name: string): Promise<TargetProfileDoc | null> {
return await TargetProfileDoc.getInstance({ name });
}
public static async findAll(): Promise<TargetProfileDoc[]> {
return await TargetProfileDoc.getInstances({});
}
-11
View File
@@ -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 -2
View File
@@ -1,11 +1,14 @@
// 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.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';
@@ -21,7 +24,20 @@ export * from './classes.cert-backoff.doc.js';
// Remote ingress document classes
export * from './classes.remote-ingress-edge.doc.js';
export * from './classes.remote-ingress-hub-settings.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';
export * from './classes.email-server-settings.doc.js';
-3
View File
@@ -1,9 +1,6 @@
// Unified database manager
export * from './classes.dcrouter-db.js';
// TTL base class and constants
export * from './classes.cached.document.js';
// Cache cleaner
export * from './classes.cache.cleaner.js';
+2
View File
@@ -0,0 +1,2 @@
export * from './manager.dns.js';
export * from './providers/index.js';
+978
View File
@@ -0,0 +1,978 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import {
DnsProviderDoc,
DomainDoc,
DnsRecordDoc,
} from '../db/documents/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js';
import { createDnsProvider } from './providers/factory.js';
import type {
TDnsRecordType,
TDnsRecordSource,
} from '../../ts_interfaces/data/dns-record.js';
import type {
TDnsProviderType,
TDnsProviderCredentials,
IDnsProviderPublic,
IProviderDomainListing,
} from '../../ts_interfaces/data/dns-provider.js';
/**
* DnsManager — owns runtime DNS state on top of the embedded DnsServer.
*
* Responsibilities:
* - Load Domain/DnsRecord docs from the DB on start
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
* smartdns, provider domains hit the provider API)
* - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy()
*
* Provider-managed domains are NEVER served from the embedded DnsServer — the
* provider stays authoritative. We only mirror their records locally for the UI
* and to track providerRecordIds for updates / deletes.
*/
export class DnsManager {
/**
* Reference to the active smartdns DnsServer (set by DcRouter once it exists).
* May be undefined if dnsScopes/dnsNsDomains aren't configured.
*/
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
/**
* Cached provider clients, keyed by DnsProviderDoc.id.
* Created lazily when a provider is first needed.
*/
private providerClients = new Map<string, IDnsProviderClient>();
constructor(private options: IDcRouterOptions) {}
// ==========================================================================
// Lifecycle
// ==========================================================================
public async start(): Promise<void> {
logger.log('info', 'DnsManager: starting');
}
public async stop(): Promise<void> {
this.providerClients.clear();
this.dnsServer = undefined;
}
/**
* Wire the embedded DnsServer instance after it has been created by
* DcRouter.setupDnsWithSocketHandler(). After this, local records on
* dcrouter-hosted domains loaded from the DB are registered with the server.
*/
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
this.dnsServer = dnsServer;
await this.applyDcrouterDomainsToDnsServer();
}
// ==========================================================================
// DcRouter-hosted domain DnsServer wiring
// ==========================================================================
/**
* Register all records from dcrouter-hosted domains in the DB with the
* embedded DnsServer. Called once after attachDnsServer().
*/
private async applyDcrouterDomainsToDnsServer(): Promise<void> {
if (!this.dnsServer) {
return;
}
const allDomains = await DomainDoc.findAll();
const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter');
let registered = 0;
for (const domain of dcrouterDomains) {
const records = await DnsRecordDoc.findByDomainId(domain.id);
for (const rec of records) {
this.registerRecordWithDnsServer(rec);
registered++;
}
}
logger.log(
'info',
`DnsManager: registered ${registered} dcrouter-hosted DNS record(s) from DB`,
);
}
/**
* Register a single record with the embedded DnsServer. The handler closure
* captures the record fields, so updates require a re-register cycle.
*/
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
if (!this.dnsServer) return;
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
if (question.name.toLowerCase() === rec.name.toLowerCase() && question.type.toUpperCase() === rec.type) {
return {
name: question.name,
type: rec.type,
class: 'IN',
ttl: rec.ttl,
data: this.parseRecordData(rec.type, rec.value),
};
}
return null;
});
}
private parseRecordData(type: TDnsRecordType, value: string): any {
switch (type) {
case 'A':
case 'AAAA':
case 'CNAME':
case 'TXT':
case 'NS':
case 'CAA':
return value;
case 'MX': {
const [priorityStr, exchange] = value.split(' ');
return { priority: parseInt(priorityStr, 10), exchange };
}
case 'SOA': {
const parts = value.split(' ');
return {
mname: parts[0],
rname: parts[1],
serial: parseInt(parts[2], 10),
refresh: parseInt(parts[3], 10),
retry: parseInt(parts[4], 10),
expire: parseInt(parts[5], 10),
minimum: parseInt(parts[6], 10),
};
}
default:
return value;
}
}
// ==========================================================================
// Provider lookup (used by ACME DNS-01 + record CRUD)
// ==========================================================================
/**
* Get the provider client for a given DnsProviderDoc id, instantiating
* (and caching) it on first use.
*/
public async getProviderClientById(providerId: string): Promise<IDnsProviderClient | null> {
const cached = this.providerClients.get(providerId);
if (cached) return cached;
const doc = await DnsProviderDoc.findById(providerId);
if (!doc) return null;
const client = createDnsProvider(doc.type, doc.credentials);
this.providerClients.set(providerId, client);
return client;
}
/**
* Find the IDnsProviderClient that owns the given FQDN (by walking up its
* labels to find a matching DomainDoc with `source === 'provider'`).
* Returns null if no provider claims this FQDN.
*
* Used by:
* - SmartAcme DNS-01 wiring in setupSmartProxy()
* - DnsRecordHandler when creating provider records
*/
public async getProviderClientForDomain(fqdn: string): Promise<IDnsProviderClient | null> {
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
const allDomains = await DomainDoc.findAll();
const providerDomains = allDomains
.filter((d) => d.source === 'provider' && d.providerId)
// longest-match wins
.sort((a, b) => b.name.length - a.name.length);
for (const domain of providerDomains) {
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
return this.getProviderClientById(domain.providerId!);
}
}
return null;
}
/**
* Find the DomainDoc that covers a given FQDN, regardless of source
* (dcrouter-hosted or provider-managed). Uses longest-suffix match.
*/
public async findDomainForFqdn(fqdn: string): Promise<DomainDoc | null> {
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
const allDomains = await DomainDoc.findAll();
// Sort by name length descending for longest-match-wins
allDomains.sort((a, b) => b.name.length - a.name.length);
for (const domain of allDomains) {
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
return domain;
}
}
return null;
}
/**
* Delete DNS records matching a name and type under a domain.
* When value is provided, only that exact record is removed so parallel ACME
* challenges for the same host can coexist.
*/
public async deleteRecordsByNameAndType(
domainId: string,
name: string,
type: TDnsRecordType,
value?: string,
): Promise<void> {
const records = await DnsRecordDoc.findByDomainId(domainId);
for (const rec of records) {
if (
rec.name.toLowerCase() === name.toLowerCase()
&& rec.type === type
&& (value === undefined || rec.value === value)
) {
await this.deleteRecord(rec.id);
}
}
}
/**
* True if any domain is under management (dcrouter-hosted or provider-managed).
* Used by setupSmartProxy() to decide whether to wire SmartAcme with a DNS-01 handler.
*/
public async hasAnyManagedDomain(): Promise<boolean> {
const domains = await DomainDoc.findAll();
return domains.length > 0;
}
/**
* Build an IConvenientDnsProvider that routes ACME DNS-01 challenges through
* the DnsManager abstraction. Challenges are dispatched via createRecord() /
* deleteRecord(), which transparently handle both dcrouter-hosted zones
* (embedded DnsServer) and provider-managed zones (e.g. Cloudflare API).
*
* Only domains under management (with a DomainDoc in DB) are supported —
* this acts as the management gate for certificate issuance.
*/
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
const self = this;
const adapter = {
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
if (!domainDoc) {
throw new Error(
`DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` +
'Add the domain in Domains before issuing certificates.',
);
}
// Clean only the same challenge value. Exact + wildcard SAN orders can
// legitimately need multiple TXT records at the same name.
try {
await self.deleteRecordsByNameAndType(
domainDoc.id,
dnsChallenge.hostName,
'TXT',
dnsChallenge.challenge,
);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
// Create the challenge TXT record via the unified path
await self.createRecord({
domainId: domainDoc.id,
name: dnsChallenge.hostName,
type: 'TXT',
value: dnsChallenge.challenge,
ttl: 120,
createdBy: 'acme-dns01',
});
},
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
if (!domainDoc) {
// The domain may have been removed; nothing to clean up.
return;
}
try {
await self.deleteRecordsByNameAndType(
domainDoc.id,
dnsChallenge.hostName,
'TXT',
dnsChallenge.challenge,
);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
},
async isDomainSupported(domain: string): Promise<boolean> {
const domainDoc = await self.findDomainForFqdn(domain);
return !!domainDoc;
},
};
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
}
// ==========================================================================
// Provider CRUD (used by DnsProviderHandler)
// ==========================================================================
public async listProviders(): Promise<IDnsProviderPublic[]> {
const docs = await DnsProviderDoc.findAll();
return docs.map((d) => this.toPublicProvider(d));
}
public async getProvider(id: string): Promise<IDnsProviderPublic | null> {
const doc = await DnsProviderDoc.findById(id);
return doc ? this.toPublicProvider(doc) : null;
}
public async createProvider(args: {
name: string;
type: TDnsProviderType;
credentials: TDnsProviderCredentials;
createdBy: string;
}): Promise<string> {
if (args.type === 'dcrouter') {
throw new Error(
'createProvider: cannot create a DnsProviderDoc with type "dcrouter" — ' +
'that type is reserved for the built-in pseudo-provider surfaced at read time.',
);
}
const now = Date.now();
const doc = new DnsProviderDoc();
doc.id = plugins.uuid.v4();
doc.name = args.name;
doc.type = args.type;
doc.credentials = args.credentials;
doc.status = 'untested';
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
await doc.save();
return doc.id;
}
public async updateProvider(
id: string,
args: { name?: string; credentials?: TDnsProviderCredentials },
): Promise<boolean> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) return false;
if (args.name !== undefined) doc.name = args.name;
if (args.credentials !== undefined) {
doc.credentials = args.credentials;
doc.status = 'untested';
doc.lastError = undefined;
// Invalidate cached client so the next use re-instantiates with the new credentials.
this.providerClients.delete(id);
}
doc.updatedAt = Date.now();
await doc.save();
return true;
}
public async deleteProvider(id: string, force: boolean): Promise<{ success: boolean; message?: string }> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) return { success: false, message: 'Provider not found' };
const linkedDomains = await DomainDoc.findByProviderId(id);
if (linkedDomains.length > 0 && !force) {
return {
success: false,
message: `Provider is referenced by ${linkedDomains.length} domain(s). Pass force: true to delete anyway.`,
};
}
// If forcing, also delete the linked domains and their records.
if (force) {
for (const domain of linkedDomains) {
await this.deleteDomain(domain.id);
}
}
await doc.delete();
this.providerClients.delete(id);
return { success: true };
}
public async testProvider(id: string): Promise<{ ok: boolean; error?: string; testedAt: number }> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) {
return { ok: false, error: 'Provider not found', testedAt: Date.now() };
}
const client = createDnsProvider(doc.type, doc.credentials);
const result = await client.testConnection();
doc.status = result.ok ? 'ok' : 'error';
doc.lastTestedAt = Date.now();
doc.lastError = result.ok ? undefined : result.error;
await doc.save();
if (result.ok) {
this.providerClients.set(id, client); // cache the working client
}
return { ok: result.ok, error: result.error, testedAt: doc.lastTestedAt };
}
public async listProviderDomains(providerId: string): Promise<IProviderDomainListing[]> {
const client = await this.getProviderClientById(providerId);
if (!client) {
throw new Error('Provider not found');
}
return await client.listDomains();
}
// ==========================================================================
// Domain CRUD (used by DomainHandler)
// ==========================================================================
public async listDomains(): Promise<DomainDoc[]> {
return await DomainDoc.findAll();
}
public async getDomain(id: string): Promise<DomainDoc | null> {
return await DomainDoc.findById(id);
}
/**
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
* DNS records for this domain via the embedded smartdns.DnsServer.
*/
public async createDcrouterDomain(args: {
name: string;
description?: string;
createdBy: string;
}): Promise<string> {
const now = Date.now();
const doc = new DomainDoc();
doc.id = plugins.uuid.v4();
doc.name = args.name.toLowerCase();
doc.source = 'dcrouter';
doc.authoritative = true;
doc.description = args.description;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
await doc.save();
return doc.id;
}
/**
* Import one or more domains from a provider, pulling all of their DNS
* records into local DnsRecordDocs.
*/
public async importDomainsFromProvider(args: {
providerId: string;
domainNames: string[];
createdBy: string;
}): Promise<string[]> {
const provider = await DnsProviderDoc.findById(args.providerId);
if (!provider) {
throw new Error('Provider not found');
}
const client = await this.getProviderClientById(args.providerId);
if (!client) {
throw new Error('Failed to instantiate provider client');
}
const allProviderDomains = await client.listDomains();
const importedIds: string[] = [];
const now = Date.now();
for (const wantedName of args.domainNames) {
const lower = wantedName.toLowerCase();
const listing = allProviderDomains.find((d) => d.name.toLowerCase() === lower);
if (!listing) {
logger.log('warn', `DnsManager: import skipped — provider does not list domain ${wantedName}`);
continue;
}
// Skip if already imported
const existing = await DomainDoc.findByName(lower);
if (existing) {
logger.log('warn', `DnsManager: domain ${wantedName} already imported — skipping`);
continue;
}
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = lower;
domain.source = 'provider';
domain.providerId = args.providerId;
domain.authoritative = false;
domain.nameservers = listing.nameservers;
domain.externalZoneId = listing.externalId;
domain.lastSyncedAt = now;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = args.createdBy;
await domain.save();
importedIds.push(domain.id);
// Pull records for the imported domain
try {
const providerRecords = await client.listRecords(lower);
for (const pr of providerRecords) {
await this.createSyncedRecord(domain.id, pr, args.createdBy);
}
logger.log('info', `DnsManager: imported ${providerRecords.length} record(s) for ${lower}`);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to import records for ${lower}: ${(err as Error).message}`);
}
}
return importedIds;
}
public async updateDomain(id: string, args: { description?: string }): Promise<boolean> {
const doc = await DomainDoc.findById(id);
if (!doc) return false;
if (args.description !== undefined) doc.description = args.description;
doc.updatedAt = Date.now();
await doc.save();
return true;
}
/**
* Delete a domain and all of its DNS records. For provider domains, only
* removes the local mirror — does NOT touch the provider.
* For dcrouter-hosted domains, also unregisters records from the embedded
* DnsServer.
*
* Note: smartdns has no public unregister-by-name API in the version pinned
* here, so local record deletes only take effect after a restart. The DB
* is the source of truth and the next start will not register the deleted
* record.
*/
public async deleteDomain(id: string): Promise<boolean> {
const doc = await DomainDoc.findById(id);
if (!doc) return false;
const records = await DnsRecordDoc.findByDomainId(id);
for (const r of records) {
await r.delete();
}
await doc.delete();
return true;
}
/**
* Force-resync a provider-managed domain: re-pull all records from the
* provider API, replacing the cached DnsRecordDocs.
*/
public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; message?: string }> {
const doc = await DomainDoc.findById(id);
if (!doc) return { success: false, message: 'Domain not found' };
if (doc.source !== 'provider' || !doc.providerId) {
return { success: false, message: 'Domain is not provider-managed' };
}
const client = await this.getProviderClientById(doc.providerId);
if (!client) {
return { success: false, message: 'Provider client unavailable' };
}
const providerRecords = await client.listRecords(doc.name);
// Drop existing records and replace
const existing = await DnsRecordDoc.findByDomainId(id);
for (const r of existing) {
await r.delete();
}
for (const pr of providerRecords) {
await this.createSyncedRecord(id, pr, doc.createdBy);
}
doc.lastSyncedAt = Date.now();
doc.updatedAt = doc.lastSyncedAt;
await doc.save();
return { success: true, recordCount: providerRecords.length };
}
// ==========================================================================
// Record CRUD (used by DnsRecordHandler)
// ==========================================================================
public async listRecordsForDomain(domainId: string): Promise<DnsRecordDoc[]> {
return await DnsRecordDoc.findByDomainId(domainId);
}
public async getRecord(id: string): Promise<DnsRecordDoc | null> {
return await DnsRecordDoc.findById(id);
}
// ==========================================================================
// Domain migration
// ==========================================================================
/**
* Migrate a domain between dcrouter-hosted and provider-managed.
* Transfers all records to the target and updates domain metadata.
*/
public async migrateDomain(args: {
id: string;
targetSource: 'dcrouter' | 'provider';
targetProviderId?: string;
deleteExistingProviderRecords?: boolean;
}): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
const domain = await DomainDoc.findById(args.id);
if (!domain) return { success: false, message: 'Domain not found' };
if (domain.source === args.targetSource && domain.providerId === args.targetProviderId) {
return { success: false, message: 'Domain is already in the target configuration' };
}
const records = await DnsRecordDoc.findByDomainId(domain.id);
if (args.targetSource === 'provider') {
return this.migrateToDnsProvider(domain, records, args.targetProviderId!, args.deleteExistingProviderRecords ?? false);
} else {
return this.migrateToDcrouter(domain, records);
}
}
/**
* Migrate domain from dcrouter-hosted (or another provider) to an external DNS provider.
*/
private async migrateToDnsProvider(
domain: DomainDoc,
records: DnsRecordDoc[],
targetProviderId: string,
deleteExistingProviderRecords: boolean,
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
// Validate the target provider exists
const client = await this.getProviderClientById(targetProviderId);
if (!client) {
return { success: false, message: 'Target DNS provider not found' };
}
// Find the zone at the provider
const providerDomains = await client.listDomains();
const zone = providerDomains.find(
(z) => z.name.toLowerCase() === domain.name.toLowerCase(),
);
if (!zone) {
return { success: false, message: `Zone "${domain.name}" not found at the target provider` };
}
// Optionally delete existing records at the provider
if (deleteExistingProviderRecords) {
try {
const existingProviderRecords = await client.listRecords(domain.name);
for (const pr of existingProviderRecords) {
await client.deleteRecord(domain.name, pr.providerRecordId).catch(() => {});
}
logger.log('info', `Deleted ${existingProviderRecords.length} existing records at provider for ${domain.name}`);
} catch (err: unknown) {
logger.log('warn', `Failed to clean existing provider records for ${domain.name}: ${(err as Error).message}`);
}
}
// Push each local record to the provider
let migrated = 0;
for (const rec of records) {
try {
const providerRecord = await client.createRecord(domain.name, {
name: rec.name,
type: rec.type as any,
value: rec.value,
ttl: rec.ttl,
});
// Unregister from embedded DnsServer if it was dcrouter-hosted
if (domain.source === 'dcrouter') {
this.unregisterRecordFromDnsServer(rec);
}
// Update the record doc to synced
rec.source = 'synced' as TDnsRecordSource;
rec.providerRecordId = providerRecord.providerRecordId;
await rec.save();
migrated++;
} catch (err: unknown) {
logger.log('warn', `Failed to migrate record ${rec.name} ${rec.type} to provider: ${(err as Error).message}`);
}
}
// Update domain metadata
domain.source = 'provider';
domain.authoritative = false;
domain.providerId = targetProviderId;
domain.externalZoneId = zone.externalId;
domain.nameservers = zone.nameservers;
domain.lastSyncedAt = Date.now();
domain.updatedAt = Date.now();
await domain.save();
logger.log('info', `Domain ${domain.name} migrated to provider (${migrated} records)`);
return { success: true, recordsMigrated: migrated };
}
/**
* Migrate domain from provider-managed to dcrouter-hosted (authoritative).
*/
private async migrateToDcrouter(
domain: DomainDoc,
records: DnsRecordDoc[],
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
// Register each record with the embedded DnsServer
let migrated = 0;
for (const rec of records) {
try {
this.registerRecordWithDnsServer(rec);
// Update the record doc to local
rec.source = 'local' as TDnsRecordSource;
rec.providerRecordId = undefined;
await rec.save();
migrated++;
} catch (err: unknown) {
logger.log('warn', `Failed to register record ${rec.name} ${rec.type} with DnsServer: ${(err as Error).message}`);
}
}
// Update domain metadata
domain.source = 'dcrouter';
domain.authoritative = true;
domain.providerId = undefined;
domain.externalZoneId = undefined;
domain.nameservers = undefined;
domain.lastSyncedAt = undefined;
domain.updatedAt = Date.now();
await domain.save();
logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`);
return { success: true, recordsMigrated: migrated };
}
// ==========================================================================
// Record CRUD
// ==========================================================================
public async createRecord(args: {
domainId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl?: number;
proxied?: boolean;
createdBy: string;
}): Promise<{ success: boolean; id?: string; message?: string }> {
const domain = await DomainDoc.findById(args.domainId);
if (!domain) return { success: false, message: 'Domain not found' };
const now = Date.now();
const doc = new DnsRecordDoc();
doc.id = plugins.uuid.v4();
doc.domainId = args.domainId;
doc.name = args.name.toLowerCase();
doc.type = args.type;
doc.value = args.value;
doc.ttl = args.ttl ?? 300;
if (args.proxied !== undefined) doc.proxied = args.proxied;
doc.source = 'local';
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
if (domain.source === 'provider') {
// Push to provider first; only persist locally on success
if (!domain.providerId) {
return { success: false, message: 'Provider domain has no providerId' };
}
const client = await this.getProviderClientById(domain.providerId);
if (!client) return { success: false, message: 'Provider client unavailable' };
try {
const created = await client.createRecord(domain.name, {
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
});
doc.providerRecordId = created.providerRecordId;
doc.source = 'synced';
} catch (err: unknown) {
return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
}
} else {
// dcrouter-hosted / authoritative — register with embedded DnsServer immediately
this.registerRecordWithDnsServer(doc);
}
await doc.save();
return { success: true, id: doc.id };
}
public async updateRecord(args: {
id: string;
name?: string;
value?: string;
ttl?: number;
proxied?: boolean;
}): Promise<{ success: boolean; message?: string }> {
const doc = await DnsRecordDoc.findById(args.id);
if (!doc) return { success: false, message: 'Record not found' };
const domain = await DomainDoc.findById(doc.domainId);
if (!domain) return { success: false, message: 'Parent domain not found' };
if (args.name !== undefined) doc.name = args.name.toLowerCase();
if (args.value !== undefined) doc.value = args.value;
if (args.ttl !== undefined) doc.ttl = args.ttl;
if (args.proxied !== undefined) doc.proxied = args.proxied;
doc.updatedAt = Date.now();
if (domain.source === 'provider') {
if (!domain.providerId || !doc.providerRecordId) {
return { success: false, message: 'Provider record metadata missing' };
}
const client = await this.getProviderClientById(domain.providerId);
if (!client) return { success: false, message: 'Provider client unavailable' };
try {
await client.updateRecord(domain.name, doc.providerRecordId, {
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
});
} catch (err: unknown) {
return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
}
} else {
// Re-register the local record so the new closure picks up the updated fields
this.registerRecordWithDnsServer(doc);
}
await doc.save();
return { success: true };
}
public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> {
const doc = await DnsRecordDoc.findById(id);
if (!doc) return { success: false, message: 'Record not found' };
const domain = await DomainDoc.findById(doc.domainId);
if (!domain) return { success: false, message: 'Parent domain not found' };
if (domain.source === 'provider') {
if (domain.providerId && doc.providerRecordId) {
const client = await this.getProviderClientById(domain.providerId);
if (client) {
try {
await client.deleteRecord(domain.name, doc.providerRecordId);
} catch (err: unknown) {
return { success: false, message: `Provider rejected delete: ${(err as Error).message}` };
}
}
}
}
// For dcrouter-hosted records: unregister the handler from the embedded DnsServer
// so the record stops being served immediately (not just after restart).
if (domain.source === 'dcrouter' && this.dnsServer) {
this.unregisterRecordFromDnsServer(doc);
}
await doc.delete();
return { success: true };
}
/**
* Unregister a record's handler from the embedded DnsServer.
*/
public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void {
if (!this.dnsServer) return;
this.dnsServer.unregisterHandler(rec.name, [rec.type]);
}
// ==========================================================================
// Internal helpers
// ==========================================================================
private async createSyncedRecord(
domainId: string,
pr: IProviderRecord,
createdBy: string,
): Promise<void> {
const now = Date.now();
const doc = new DnsRecordDoc();
doc.id = plugins.uuid.v4();
doc.domainId = domainId;
doc.name = pr.name.toLowerCase();
doc.type = pr.type;
doc.value = pr.value;
doc.ttl = pr.ttl;
if (pr.proxied !== undefined) doc.proxied = pr.proxied;
doc.source = 'synced';
doc.providerRecordId = pr.providerRecordId;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = createdBy;
await doc.save();
}
/**
* Convert a DnsProviderDoc to its public, secret-stripped representation
* for the OpsServer API.
*/
public toPublicProvider(doc: DnsProviderDoc): IDnsProviderPublic {
return {
id: doc.id,
name: doc.name,
type: doc.type,
status: doc.status,
lastTestedAt: doc.lastTestedAt,
lastError: doc.lastError,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
hasCredentials: !!doc.credentials,
};
}
/**
* Convert a DomainDoc to its plain interface representation.
*/
public toPublicDomain(doc: DomainDoc): {
id: string;
name: string;
source: 'dcrouter' | 'provider';
providerId?: string;
authoritative: boolean;
nameservers?: string[];
externalZoneId?: string;
lastSyncedAt?: number;
description?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
} {
return {
id: doc.id,
name: doc.name,
source: doc.source,
providerId: doc.providerId,
authoritative: doc.authoritative,
nameservers: doc.nameservers,
externalZoneId: doc.externalZoneId,
lastSyncedAt: doc.lastSyncedAt,
description: doc.description,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
/**
* Convert a DnsRecordDoc to its plain interface representation.
*/
public toPublicRecord(doc: DnsRecordDoc): {
id: string;
domainId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl: number;
proxied?: boolean;
source: TDnsRecordSource;
providerRecordId?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
} {
return {
id: doc.id,
domainId: doc.domainId,
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
source: doc.source,
providerRecordId: doc.providerRecordId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
}
+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>;
}
+442
View File
@@ -0,0 +1,442 @@
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 baseEmailDomains: IEmailDomainConfig[] = [];
constructor(dcRouterRef: any) {
this.dcRouter = dcRouterRef;
this.setBaseEmailDomains(this.dcRouter.options?.emailConfig?.domains as IEmailDomainConfig[] | undefined);
}
public setBaseEmailDomains(domains: IEmailDomainConfig[] | undefined): void {
this.baseEmailDomains = (domains || [])
.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 });
}
}
}
+221
View File
@@ -0,0 +1,221 @@
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
import { EmailServerSettingsDoc } from '../db/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type {
IEmailPortConfig,
IEmailServerSettings,
TEmailServerSettingsUpdate,
} from '../../ts_interfaces/data/email-settings.js';
const defaultEmailPorts = [25, 587, 465];
function clonePlain<T>(value: T | undefined): T | undefined {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value)) as T;
}
function hasOwn(objectArg: object, keyArg: string): boolean {
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
}
export class EmailSettingsManager {
private cachedEmailConfig?: IUnifiedEmailServerOptions;
private cachedEmailPortConfig?: IEmailPortConfig;
private enabled = false;
private updatedAt = 0;
private updatedBy = 'default';
constructor(private options: IDcRouterOptions) {}
public async start(): Promise<void> {
let doc = await EmailServerSettingsDoc.load();
if (!doc) {
doc = new EmailServerSettingsDoc();
doc.settingsId = 'email-server-settings';
doc.enabled = false;
doc.updatedAt = Date.now();
doc.updatedBy = 'default';
await doc.save();
}
this.loadFromDoc(doc);
this.applyToRuntimeOptions();
}
public async stop(): Promise<void> {
this.cachedEmailConfig = undefined;
this.cachedEmailPortConfig = undefined;
this.enabled = false;
}
public isEnabled(): boolean {
return this.enabled && Boolean(this.cachedEmailConfig);
}
public getEmailConfig(): IUnifiedEmailServerOptions | undefined {
return this.isEnabled() ? clonePlain(this.cachedEmailConfig) : undefined;
}
public getEmailPortConfig(): IEmailPortConfig | undefined {
return this.isEnabled() ? clonePlain(this.cachedEmailPortConfig) : undefined;
}
public getPublicSettings(): IEmailServerSettings {
const emailConfig = this.cachedEmailConfig;
const emailPortConfig = this.cachedEmailPortConfig;
return {
enabled: this.isEnabled(),
hostname: emailConfig?.hostname || null,
ports: [...(emailConfig?.ports || [])],
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
maxMessageSize: emailConfig?.maxMessageSize ?? null,
domainCount: emailConfig?.domains?.length || 0,
routeCount: emailConfig?.routes?.length || 0,
authUserCount: emailConfig?.auth?.users?.length || 0,
updatedAt: this.updatedAt,
updatedBy: this.updatedBy,
};
}
public async updateSettings(
updates: TEmailServerSettingsUpdate,
updatedBy: string,
): Promise<IEmailServerSettings> {
let doc = await EmailServerSettingsDoc.load();
if (!doc) {
doc = new EmailServerSettingsDoc();
doc.settingsId = 'email-server-settings';
}
const nextEnabled = hasOwn(updates, 'enabled') ? Boolean(updates.enabled) : doc.enabled;
const nextEmailConfig = this.patchEmailConfig(doc.emailConfig, updates, nextEnabled);
const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig, updates);
doc.enabled = nextEnabled;
doc.emailConfig = nextEmailConfig;
doc.emailPortConfig = nextEmailPortConfig;
doc.updatedAt = Date.now();
doc.updatedBy = updatedBy;
await doc.save();
this.loadFromDoc(doc);
this.applyToRuntimeOptions();
return this.getPublicSettings();
}
private loadFromDoc(doc: EmailServerSettingsDoc): void {
this.enabled = doc.enabled;
this.cachedEmailConfig = clonePlain(doc.emailConfig);
this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig);
this.updatedAt = doc.updatedAt;
this.updatedBy = doc.updatedBy;
}
private applyToRuntimeOptions(): void {
this.options.emailConfig = this.getEmailConfig();
this.options.emailPortConfig = this.getEmailPortConfig();
}
private patchEmailConfig(
existingConfig: IUnifiedEmailServerOptions | undefined,
updates: TEmailServerSettingsUpdate,
nextEnabled: boolean,
): IUnifiedEmailServerOptions | undefined {
const nextConfig: IUnifiedEmailServerOptions | undefined = clonePlain(existingConfig) || (nextEnabled ? {
hostname: 'localhost',
ports: [...defaultEmailPorts],
domains: [],
routes: [],
} : undefined);
if (!nextConfig) return undefined;
if (hasOwn(updates, 'hostname')) {
const hostname = updates.hostname?.trim() || '';
if (nextEnabled && !hostname) {
throw new Error('Email hostname is required when email is enabled');
}
nextConfig.hostname = hostname || nextConfig.hostname;
}
if (hasOwn(updates, 'ports')) {
nextConfig.ports = this.normalizePorts(updates.ports || []);
}
if (hasOwn(updates, 'maxMessageSize')) {
if (updates.maxMessageSize === null || updates.maxMessageSize === undefined) {
delete nextConfig.maxMessageSize;
} else {
const maxMessageSize = Number(updates.maxMessageSize);
if (!Number.isInteger(maxMessageSize) || maxMessageSize <= 0) {
throw new Error('maxMessageSize must be a positive integer');
}
nextConfig.maxMessageSize = maxMessageSize;
}
}
if (nextEnabled) {
if (!nextConfig.hostname?.trim()) {
throw new Error('Email hostname is required when email is enabled');
}
nextConfig.ports = this.normalizePorts(nextConfig.ports || []);
}
nextConfig.domains = nextConfig.domains || [];
nextConfig.routes = nextConfig.routes || [];
return nextConfig;
}
private patchEmailPortConfig(
existingPortConfig: IEmailPortConfig | undefined,
updates: TEmailServerSettingsUpdate,
): IEmailPortConfig | undefined {
const nextPortConfig: IEmailPortConfig = clonePlain(existingPortConfig) || {};
if (hasOwn(updates, 'portMapping')) {
if (updates.portMapping === null) {
delete nextPortConfig.portMapping;
} else {
nextPortConfig.portMapping = this.normalizePortMapping(updates.portMapping || {});
}
}
if (hasOwn(updates, 'receivedEmailsPath')) {
const receivedEmailsPath = updates.receivedEmailsPath?.trim() || '';
if (receivedEmailsPath) {
nextPortConfig.receivedEmailsPath = receivedEmailsPath;
} else {
delete nextPortConfig.receivedEmailsPath;
}
}
return Object.keys(nextPortConfig).length > 0 ? nextPortConfig : undefined;
}
private normalizePorts(ports: number[]): number[] {
const normalized = [...new Set(ports.map((port) => Number(port)))];
if (normalized.length === 0) {
throw new Error('At least one email port is required when email is enabled');
}
for (const port of normalized) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid email port: ${port}`);
}
}
return normalized.sort((a, b) => a - b);
}
private normalizePortMapping(portMapping: Record<number, number>): Record<number, number> {
const normalized: Record<number, number> = {};
for (const [externalPortString, internalPortValue] of Object.entries(portMapping)) {
const externalPort = Number(externalPortString);
const internalPort = Number(internalPortValue);
for (const port of [externalPort, internalPort]) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid email port mapping value: ${port}`);
}
}
normalized[externalPort] = internalPort;
}
return normalized;
}
}
@@ -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);
}
}
}
+577
View File
@@ -0,0 +1,577 @@
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'];
type TMailResourceOwner = plugins.servezoneInterfaces.data.IMailResourceOwner;
type TMailAddressBinding = plugins.servezoneInterfaces.data.IMailAddressBinding;
type TMailAddressBindingSync = plugins.servezoneInterfaces.requests.mail.TMailAddressBindingSync;
type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding['response'];
type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
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 listMailAddressBindings(options: {
owner?: Partial<TMailResourceOwner>;
domain?: string;
address?: string;
} = {}): Promise<TMailAddressBinding[]> {
const domain = options.domain ? this.normalizeDomain(options.domain) : undefined;
const address = options.address ? this.normalizeAddress(options.address) : undefined;
const identities = await this.readStoredIdentities();
return identities
.filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), options.owner))
.filter((identity) => domain ? identity.domain === domain : true)
.filter((identity) => address ? identity.address === address : true)
.map((identity) => this.toMailAddressBinding(identity));
}
public async listWorkAppMailBindings(
owner?: Partial<TMailResourceOwner>,
): Promise<TWorkAppMailBinding[]> {
const identities = (await this.readStoredIdentities())
.filter((identity) => this.matchesMailOwner(this.toMailOwner(identity.ownership), owner));
const groups = new Map<string, IStoredWorkAppMailIdentity[]>();
for (const identity of identities) {
const ownerKey = this.buildMailOwnerKey(this.toMailOwner(identity.ownership));
const group = groups.get(ownerKey) || [];
group.push(identity);
groups.set(ownerKey, group);
}
return Array.from(groups.values()).map((group) => this.toWorkAppMailBinding(group));
}
public async syncMailAddressBinding(
binding: TMailAddressBindingSync,
createdBy: string,
): Promise<TMailAddressBindingSyncResponse> {
const ownership = this.normalizeMailResourceOwner(binding.owner);
const { localPart, domain } = this.normalizeMailAddressParts(binding);
const syncRequest: TSyncRequest = {
ownership,
localPart,
domain,
inbound: this.toLegacyInboundRoute(binding.inboundTarget),
enabled: binding.enabled,
};
if (binding.outboundIdentityId !== undefined) {
syncRequest.smtpEnabled = Boolean(binding.outboundIdentityId);
}
const result = await this.syncMailIdentity(syncRequest, createdBy);
return {
success: result.success,
binding: result.identity ? this.toMailAddressBinding(result.identity) : undefined,
message: result.message,
};
}
public async deleteMailAddressBinding(
id: string,
createdBy: string,
): Promise<TMailAddressBindingDeleteResponse> {
const identities = await this.readStoredIdentities();
const identity = identities.find((storedIdentity) => storedIdentity.id === id || storedIdentity.externalKey === id);
if (!identity) {
return { success: true };
}
const result = await this.syncMailIdentity({
ownership: identity.ownership,
localPart: identity.localPart,
domain: identity.domain,
delete: true,
}, createdBy);
return {
success: result.success,
message: result.message,
};
}
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 normalizeAddress(address: string): string {
const normalized = address?.trim().toLowerCase();
const [localPart, domain, extra] = normalized?.split('@') || [];
if (!localPart || !domain || extra) {
throw new Error(`Invalid email address: ${address}`);
}
return `${this.normalizeLocalPart(localPart)}@${this.normalizeDomain(domain)}`;
}
private normalizeMailResourceOwner(owner: TMailResourceOwner): interfaces.data.IWorkAppMailOwnership {
const gatewayClientType = owner.gatewayClientType;
const gatewayClientId = owner.gatewayClientId?.trim();
const appInstanceId = owner.appInstanceId?.trim();
if (gatewayClientType !== 'onebox' && gatewayClientType !== 'cloudly' && gatewayClientType !== 'custom') {
throw new Error(`Invalid gateway client type: ${gatewayClientType}`);
}
if (!gatewayClientId) throw new Error('gatewayClientId is required');
if (!appInstanceId) throw new Error('appInstanceId is required');
return {
workHosterType: gatewayClientType as interfaces.data.TGatewayClientType,
workHosterId: gatewayClientId,
workAppId: appInstanceId,
};
}
private normalizeMailAddressParts(binding: TMailAddressBindingSync): {
localPart: string;
domain: string;
} {
const localPart = this.normalizeLocalPart(binding.localPart);
const domain = this.normalizeDomain(binding.domain);
const address = this.normalizeAddress(binding.address);
if (address !== `${localPart}@${domain}`) {
throw new Error('mail address, localPart, and domain do not match');
}
return { localPart, domain };
}
private toLegacyInboundRoute(
inboundTarget?: TMailAddressBinding['inboundTarget'],
): interfaces.data.IWorkAppMailInboundRoute | undefined {
if (!inboundTarget) return undefined;
if (inboundTarget.type !== 'smtpForward' || !inboundTarget.smtpForward) {
throw new Error(`Unsupported WorkApp mail inbound target: ${inboundTarget.type}`);
}
return this.normalizeInboundRoute({
enabled: true,
targetHost: inboundTarget.smtpForward.host,
targetPort: inboundTarget.smtpForward.port,
preserveHeaders: inboundTarget.smtpForward.preserveHeaders,
addHeaders: inboundTarget.smtpForward.addHeaders,
});
}
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 matchesMailOwner(
owner: TMailResourceOwner,
filter?: Partial<TMailResourceOwner>,
): boolean {
if (!filter) return true;
if (filter.gatewayClientType && filter.gatewayClientType !== owner.gatewayClientType) return false;
if (filter.gatewayClientId && filter.gatewayClientId !== owner.gatewayClientId) return false;
if (filter.appInstanceId && filter.appInstanceId !== owner.appInstanceId) 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 buildMailOwnerKey(owner: TMailResourceOwner): string {
return [
owner.gatewayClientType,
owner.gatewayClientId,
owner.appInstanceId,
].join(':');
}
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 toMailOwner(ownership: interfaces.data.IWorkAppMailOwnership): TMailResourceOwner & { appInstanceId: string } {
return {
gatewayClientType: ownership.workHosterType,
gatewayClientId: ownership.workHosterId,
appInstanceId: ownership.workAppId,
};
}
private toMailInboundTarget(
inbound?: interfaces.data.IWorkAppMailInboundRoute,
): TMailAddressBinding['inboundTarget'] {
if (!inbound?.enabled) return undefined;
return {
type: 'smtpForward',
smtpForward: {
host: inbound.targetHost,
port: inbound.targetPort,
preserveHeaders: inbound.preserveHeaders,
addHeaders: inbound.addHeaders,
},
};
}
private toMailAddressBinding(
identity: interfaces.data.IWorkAppMailIdentity,
): TMailAddressBinding {
return {
id: identity.id,
owner: this.toMailOwner(identity.ownership),
address: identity.address,
localPart: identity.localPart,
domain: identity.domain,
enabled: identity.enabled,
status: identity.enabled ? 'active' : 'disabled',
inboundTarget: this.toMailInboundTarget(identity.inbound),
outboundIdentityId: identity.smtp.enabled ? identity.smtp.username : undefined,
recipientPolicy: {
mode: 'staticList',
staticRecipients: [identity.address],
},
createdAt: identity.createdAt,
updatedAt: identity.updatedAt,
createdBy: identity.createdBy,
};
}
private toWorkAppMailBinding(
identities: IStoredWorkAppMailIdentity[],
): TWorkAppMailBinding {
const [firstIdentity] = identities;
const owner = this.toMailOwner(firstIdentity.ownership);
const enabledIdentities = identities.filter((identity) => identity.enabled);
const smtpIdentities = identities.filter((identity) => identity.smtp.enabled);
return {
id: `workapp-mail-${this.hashExternalKey(this.buildMailOwnerKey(owner)).slice(0, 32)}`,
owner,
enabled: enabledIdentities.length > 0,
status: enabledIdentities.length > 0 ? 'active' : 'disabled',
addressBindingIds: identities.map((identity) => identity.id),
outboundIdentityIds: smtpIdentities.map((identity) => identity.smtp.username),
defaultFrom: enabledIdentities[0]?.address || firstIdentity.address,
inboundTarget: identities.length === 1 ? this.toMailInboundTarget(firstIdentity.inbound) : undefined,
createdAt: Math.min(...identities.map((identity) => identity.createdAt)),
updatedAt: Math.max(...identities.map((identity) => identity.updatedAt)),
createdBy: firstIdentity.createdBy,
};
}
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;
}
+5
View File
@@ -0,0 +1,5 @@
export * from './classes.email-domain.manager.js';
export * from './classes.email-settings.manager.js';
export * from './classes.smartmta-storage-manager.js';
export * from './classes.workapp-mail-manager.js';
export * from './email-dns-records.js';
+2 -18
View File
@@ -1,4 +1,4 @@
import type * as plugins from '../plugins.js';
import * as plugins from '../plugins.js';
/**
* Configuration for HTTP/3 (QUIC) route augmentation.
@@ -36,22 +36,6 @@ export interface IHttp3Config {
};
}
type TPortRange = plugins.smartproxy.IRouteConfig['match']['ports'];
/**
* Check whether a TPortRange includes port 443.
*/
function portRangeIncludes443(ports: TPortRange): boolean {
if (typeof ports === 'number') return ports === 443;
if (Array.isArray(ports)) {
return ports.some((p) => {
if (typeof p === 'number') return p === 443;
return p.from <= 443 && p.to >= 443;
});
}
return false;
}
/**
* Check if a route name indicates an email route that should not get HTTP/3.
*/
@@ -85,7 +69,7 @@ export function routeQualifiesForHttp3(
if (route.action.type !== 'forward') return false;
// Must include port 443
if (!portRangeIncludes443(route.match.ports)) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
// Must have TLS
if (!route.action.tls) return false;
+24
View File
@@ -1,3 +1,4 @@
import { commitinfo } from './00_commitinfo_data.js';
export * from './00_commitinfo_data.js';
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
@@ -18,6 +19,29 @@ export * from './remoteingress/index.js';
export type { IHttp3Config } from './http3/index.js';
export const runCli = async () => {
const args = process.argv.slice(2);
if (args.includes('--version') || args.includes('version')) {
console.log(commitinfo.version);
return;
}
if (args.includes('--help') || args.includes('-h') || args.includes('help')) {
console.log(`dcrouter ${commitinfo.version}
Usage:
dcrouter
dcrouter --version
dcrouter --help
Environment:
DCROUTER_MODE=OCI_CONTAINER Start with OCI container configuration
DCROUTER_DNS_BIND_INTERFACE Override the embedded DNS UDP bind address
DATA_DIR=<path> Override the writable dcrouter data directory
`);
return;
}
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {

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