Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0386beb15 | |||
| 1d7e5495fa | |||
| 9a378ae87f | |||
| 58fbc2b1e4 | |||
| 20ea0ce683 | |||
| bcea93753b | |||
| 848515e424 | |||
| 38c9978969 | |||
| ee863b8178 | |||
| 9bb5a8bcc1 |
@@ -1,5 +1,40 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
|
||||||
normalize target profile route references and stabilize VPN host-IP client routing behavior
|
normalize target profile route references and stabilize VPN host-IP client routing behavior
|
||||||
|
|
||||||
|
|||||||
+9
-8
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "13.17.5",
|
"version": "13.18.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --logfile --timeout 60)",
|
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||||
"start": "(node ./cli.js)",
|
"start": "(node ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
"@git.zone/tsrun": "^2.0.2",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^3.6.3",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@git.zone/tswatch": "^3.3.2",
|
"@git.zone/tswatch": "^3.3.2",
|
||||||
"@types/node": "^25.6.0"
|
"@types/node": "^25.6.0",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.3.0",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
@@ -50,11 +51,11 @@
|
|||||||
"@push.rocks/smartlog": "^3.2.2",
|
"@push.rocks/smartlog": "^3.2.2",
|
||||||
"@push.rocks/smartmetrics": "^3.0.3",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
"@push.rocks/smartmigration": "1.2.0",
|
"@push.rocks/smartmigration": "1.2.0",
|
||||||
"@push.rocks/smartmta": "^5.3.1",
|
"@push.rocks/smartmta": "^5.3.3",
|
||||||
"@push.rocks/smartnetwork": "^4.5.2",
|
"@push.rocks/smartnetwork": "^4.6.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^27.6.0",
|
"@push.rocks/smartproxy": "^27.7.4",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
@@ -62,12 +63,12 @@
|
|||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartvpn": "1.19.2",
|
"@push.rocks/smartvpn": "1.19.2",
|
||||||
"@push.rocks/taskbuffer": "^8.0.2",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/catalog": "^2.12.3",
|
"@serve.zone/catalog": "^2.12.4",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^4.15.3",
|
"@serve.zone/remoteingress": "^4.15.3",
|
||||||
"@tsclass/tsclass": "^9.5.0",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"lru-cache": "^11.3.3",
|
"lru-cache": "^11.3.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+58
-55
@@ -69,11 +69,11 @@ importers:
|
|||||||
specifier: 1.2.0
|
specifier: 1.2.0
|
||||||
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
|
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
|
||||||
'@push.rocks/smartmta':
|
'@push.rocks/smartmta':
|
||||||
specifier: ^5.3.1
|
specifier: ^5.3.3
|
||||||
version: 5.3.1
|
version: 5.3.3
|
||||||
'@push.rocks/smartnetwork':
|
'@push.rocks/smartnetwork':
|
||||||
specifier: ^4.5.2
|
specifier: ^4.6.0
|
||||||
version: 4.5.2
|
version: 4.6.0
|
||||||
'@push.rocks/smartpath':
|
'@push.rocks/smartpath':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -81,8 +81,8 @@ importers:
|
|||||||
specifier: ^4.2.3
|
specifier: ^4.2.3
|
||||||
version: 4.2.3
|
version: 4.2.3
|
||||||
'@push.rocks/smartproxy':
|
'@push.rocks/smartproxy':
|
||||||
specifier: ^27.6.0
|
specifier: ^27.7.4
|
||||||
version: 27.6.0
|
version: 27.7.4
|
||||||
'@push.rocks/smartradius':
|
'@push.rocks/smartradius':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
@@ -105,8 +105,8 @@ importers:
|
|||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
'@serve.zone/catalog':
|
'@serve.zone/catalog':
|
||||||
specifier: ^2.12.3
|
specifier: ^2.12.4
|
||||||
version: 2.12.3(@tiptap/pm@2.27.2)
|
version: 2.12.4(@tiptap/pm@2.27.2)
|
||||||
'@serve.zone/interfaces':
|
'@serve.zone/interfaces':
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
@@ -120,8 +120,8 @@ importers:
|
|||||||
specifier: ^1.5.6
|
specifier: ^1.5.6
|
||||||
version: 1.5.6
|
version: 1.5.6
|
||||||
lru-cache:
|
lru-cache:
|
||||||
specifier: ^11.3.3
|
specifier: ^11.3.5
|
||||||
version: 11.3.3
|
version: 11.3.5
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
@@ -147,6 +147,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.6.0
|
specifier: ^25.6.0
|
||||||
version: 25.6.0
|
version: 25.6.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.2
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -365,9 +368,6 @@ packages:
|
|||||||
'@design.estate/dees-element@2.2.4':
|
'@design.estate/dees-element@2.2.4':
|
||||||
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.0':
|
|
||||||
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.9.0':
|
'@design.estate/dees-wcctools@3.9.0':
|
||||||
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
||||||
|
|
||||||
@@ -1251,8 +1251,8 @@ packages:
|
|||||||
'@push.rocks/smartmongo@5.1.1':
|
'@push.rocks/smartmongo@5.1.1':
|
||||||
resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==}
|
resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==}
|
||||||
|
|
||||||
'@push.rocks/smartmta@5.3.1':
|
'@push.rocks/smartmta@5.3.3':
|
||||||
resolution: {integrity: sha512-cEuXO56i/zL9eZS79eAesEW16ikdBJKLlEv9pLKkt2cmaHBWADGHjeOzJmsszQ9CSFcuhd41aHYVGMZXVvsG2g==}
|
resolution: {integrity: sha512-QxNob2yosDOhHMMjfUiQHfx8z+/UQQUdZY4ECATg3/xAMwnychR41IEVp6h7Qz3RjoJqS3NjRBThm9/jT02Gxg==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
cpu: [x64, arm64]
|
cpu: [x64, arm64]
|
||||||
os: [darwin, linux, win32]
|
os: [darwin, linux, win32]
|
||||||
@@ -1260,8 +1260,8 @@ packages:
|
|||||||
'@push.rocks/smartmustache@3.0.2':
|
'@push.rocks/smartmustache@3.0.2':
|
||||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||||
|
|
||||||
'@push.rocks/smartnetwork@4.5.2':
|
'@push.rocks/smartnetwork@4.6.0':
|
||||||
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
|
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
|
||||||
|
|
||||||
'@push.rocks/smartnftables@1.1.0':
|
'@push.rocks/smartnftables@1.1.0':
|
||||||
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||||
@@ -1287,8 +1287,8 @@ packages:
|
|||||||
'@push.rocks/smartpromise@4.2.3':
|
'@push.rocks/smartpromise@4.2.3':
|
||||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||||
|
|
||||||
'@push.rocks/smartproxy@27.6.0':
|
'@push.rocks/smartproxy@27.7.4':
|
||||||
resolution: {integrity: sha512-1mPzabUKhlC0EdeI7Hjee/aiptTsOLftbq8oWBTlIg9JhCQwkIs5UNGTJV/VvlEflJKnay8TbzLzlr95gUr/1w==}
|
resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==}
|
||||||
|
|
||||||
'@push.rocks/smartpuppeteer@2.0.5':
|
'@push.rocks/smartpuppeteer@2.0.5':
|
||||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||||
@@ -1591,8 +1591,8 @@ packages:
|
|||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||||
|
|
||||||
'@serve.zone/catalog@2.12.3':
|
'@serve.zone/catalog@2.12.4':
|
||||||
resolution: {integrity: sha512-/QLFjFcy/ig6cdr4517smSc/VCutW/qF/8lCM3v7tpQ5yLApjqiL314Dyvk9zzSwHpw69IeuM9EmPOeTuCY0iQ==}
|
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
|
||||||
|
|
||||||
'@serve.zone/interfaces@5.3.0':
|
'@serve.zone/interfaces@5.3.0':
|
||||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||||
@@ -3023,6 +3023,9 @@ packages:
|
|||||||
libmime@5.3.7:
|
libmime@5.3.7:
|
||||||
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
|
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
|
||||||
|
|
||||||
|
libmime@5.3.8:
|
||||||
|
resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==}
|
||||||
|
|
||||||
libqp@2.1.1:
|
libqp@2.1.1:
|
||||||
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
||||||
|
|
||||||
@@ -3085,8 +3088,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
lru-cache@11.3.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
|
lru-cache@11.3.5:
|
||||||
|
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
lru-cache@7.18.3:
|
lru-cache@7.18.3:
|
||||||
@@ -3096,8 +3102,8 @@ packages:
|
|||||||
lucide@1.8.0:
|
lucide@1.8.0:
|
||||||
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
|
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
|
||||||
|
|
||||||
mailparser@3.9.6:
|
mailparser@3.9.8:
|
||||||
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==}
|
resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==}
|
||||||
|
|
||||||
make-dir@3.1.0:
|
make-dir@3.1.0:
|
||||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||||
@@ -3434,8 +3440,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
||||||
engines: {node: '>= 6.13.0'}
|
engines: {node: '>= 6.13.0'}
|
||||||
|
|
||||||
nodemailer@8.0.4:
|
nodemailer@8.0.5:
|
||||||
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
|
resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
normalize-newline@4.1.0:
|
normalize-newline@4.1.0:
|
||||||
@@ -4937,18 +4943,6 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.0':
|
|
||||||
dependencies:
|
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
|
||||||
'@design.estate/dees-element': 2.2.4
|
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
|
||||||
lit: 3.3.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@nuxt/kit'
|
|
||||||
- react
|
|
||||||
- supports-color
|
|
||||||
- vue
|
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.9.0':
|
'@design.estate/dees-wcctools@3.9.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
@@ -5169,7 +5163,7 @@ snapshots:
|
|||||||
'@push.rocks/smartjson': 6.0.0
|
'@push.rocks/smartjson': 6.0.0
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
|
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@push.rocks/smartrequest': 5.0.1
|
'@push.rocks/smartrequest': 5.0.1
|
||||||
@@ -5972,7 +5966,7 @@ snapshots:
|
|||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartdns': 7.9.0
|
'@push.rocks/smartdns': 7.9.0
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartstring': 4.1.0
|
'@push.rocks/smartstring': 4.1.0
|
||||||
'@push.rocks/smarttime': 4.2.3
|
'@push.rocks/smarttime': 4.2.3
|
||||||
'@push.rocks/smartunique': 3.0.9
|
'@push.rocks/smartunique': 3.0.9
|
||||||
@@ -6420,7 +6414,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@push.rocks/smartmta@5.3.1':
|
'@push.rocks/smartmta@5.3.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartfile': 13.1.2
|
'@push.rocks/smartfile': 13.1.2
|
||||||
'@push.rocks/smartfs': 1.5.0
|
'@push.rocks/smartfs': 1.5.0
|
||||||
@@ -6429,8 +6423,8 @@ snapshots:
|
|||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
'@tsclass/tsclass': 9.5.0
|
'@tsclass/tsclass': 9.5.0
|
||||||
lru-cache: 11.3.3
|
lru-cache: 10.4.3
|
||||||
mailparser: 3.9.6
|
mailparser: 3.9.8
|
||||||
uuid: 13.0.0
|
uuid: 13.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -6439,7 +6433,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
handlebars: 4.7.9
|
handlebars: 4.7.9
|
||||||
|
|
||||||
'@push.rocks/smartnetwork@4.5.2':
|
'@push.rocks/smartnetwork@4.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartdns': 7.9.0
|
'@push.rocks/smartdns': 7.9.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
@@ -6500,7 +6494,7 @@ snapshots:
|
|||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartfs': 1.5.0
|
'@push.rocks/smartfs': 1.5.0
|
||||||
'@push.rocks/smartjimp': 1.2.0
|
'@push.rocks/smartjimp': 1.2.0
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
|
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
|
||||||
@@ -6521,7 +6515,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/smartpromise@4.2.3': {}
|
'@push.rocks/smartpromise@4.2.3': {}
|
||||||
|
|
||||||
'@push.rocks/smartproxy@27.6.0':
|
'@push.rocks/smartproxy@27.7.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartcrypto': 2.0.4
|
'@push.rocks/smartcrypto': 2.0.4
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
@@ -6923,12 +6917,12 @@ snapshots:
|
|||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
selderee: 0.11.0
|
selderee: 0.11.0
|
||||||
|
|
||||||
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
'@serve.zone/catalog@2.12.4(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
'@design.estate/dees-wcctools': 3.8.0
|
'@design.estate/dees-wcctools': 3.9.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
- '@tiptap/pm'
|
- '@tiptap/pm'
|
||||||
@@ -8603,6 +8597,13 @@ snapshots:
|
|||||||
libbase64: 1.3.0
|
libbase64: 1.3.0
|
||||||
libqp: 2.1.1
|
libqp: 2.1.1
|
||||||
|
|
||||||
|
libmime@5.3.8:
|
||||||
|
dependencies:
|
||||||
|
encoding-japanese: 2.2.0
|
||||||
|
iconv-lite: 0.7.2
|
||||||
|
libbase64: 1.3.0
|
||||||
|
libqp: 2.1.1
|
||||||
|
|
||||||
libqp@2.1.1: {}
|
libqp@2.1.1: {}
|
||||||
|
|
||||||
lightweight-charts@5.1.0:
|
lightweight-charts@5.1.0:
|
||||||
@@ -8659,22 +8660,24 @@ snapshots:
|
|||||||
|
|
||||||
lowercase-keys@3.0.0: {}
|
lowercase-keys@3.0.0: {}
|
||||||
|
|
||||||
lru-cache@11.3.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
|
lru-cache@11.3.5: {}
|
||||||
|
|
||||||
lru-cache@7.18.3: {}
|
lru-cache@7.18.3: {}
|
||||||
|
|
||||||
lucide@1.8.0: {}
|
lucide@1.8.0: {}
|
||||||
|
|
||||||
mailparser@3.9.6:
|
mailparser@3.9.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@zone-eu/mailsplit': 5.4.8
|
'@zone-eu/mailsplit': 5.4.8
|
||||||
encoding-japanese: 2.2.0
|
encoding-japanese: 2.2.0
|
||||||
he: 1.2.0
|
he: 1.2.0
|
||||||
html-to-text: 9.0.5
|
html-to-text: 9.0.5
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
libmime: 5.3.7
|
libmime: 5.3.8
|
||||||
linkify-it: 5.0.0
|
linkify-it: 5.0.0
|
||||||
nodemailer: 8.0.4
|
nodemailer: 8.0.5
|
||||||
punycode.js: 2.3.1
|
punycode.js: 2.3.1
|
||||||
tlds: 1.261.0
|
tlds: 1.261.0
|
||||||
|
|
||||||
@@ -9179,7 +9182,7 @@ snapshots:
|
|||||||
|
|
||||||
node-forge@1.4.0: {}
|
node-forge@1.4.0: {}
|
||||||
|
|
||||||
nodemailer@8.0.4: {}
|
nodemailer@8.0.5: {}
|
||||||
|
|
||||||
normalize-newline@4.1.0:
|
normalize-newline@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9304,7 +9307,7 @@ snapshots:
|
|||||||
|
|
||||||
path-scurry@2.0.2:
|
path-scurry@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 11.3.3
|
lru-cache: 11.3.5
|
||||||
minipass: 7.1.3
|
minipass: 7.1.3
|
||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
|
|
||||||
// Verify unified email server was initialized
|
// Verify unified email server was initialized
|
||||||
expect(router.emailServer).toBeTruthy();
|
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
|
// Stop the router
|
||||||
await router.stop();
|
await router.stop();
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||||
|
import { RouteConfigManager } from '../ts/config/index.js';
|
||||||
|
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
|
||||||
|
import { DnsManager } from '../ts/dns/manager.dns.js';
|
||||||
|
import { logger } from '../ts/logger.js';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
const createTestDb = async () => {
|
||||||
|
const storagePath = plugins.path.join(
|
||||||
|
plugins.os.tmpdir(),
|
||||||
|
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
const db = DcRouterDb.getInstance({
|
||||||
|
storagePath,
|
||||||
|
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
});
|
||||||
|
await db.start();
|
||||||
|
await db.getDb().mongoDb.createCollection('__test_init');
|
||||||
|
|
||||||
|
return {
|
||||||
|
async cleanup() {
|
||||||
|
await db.stop();
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const testDbPromise = createTestDb();
|
||||||
|
|
||||||
|
const clearTestState = async () => {
|
||||||
|
for (const route of await RouteDoc.findAll()) {
|
||||||
|
await route.delete();
|
||||||
|
}
|
||||||
|
for (const domain of await DomainDoc.findAll()) {
|
||||||
|
await domain.delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('RouteConfigManager applies runtime DoH routes without persisting 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 appliedRoutes: any[][] = [];
|
||||||
|
const smartProxy = {
|
||||||
|
updateRoutes: async (routes: any[]) => {
|
||||||
|
appliedRoutes.push(routes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeManager = new RouteConfigManager(
|
||||||
|
() => smartProxy as any,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
() => (dcRouter as any).generateDnsRoutes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await routeManager.initialize([], [], []);
|
||||||
|
await routeManager.applyRoutes();
|
||||||
|
|
||||||
|
const persistedRoutes = await RouteDoc.findAll();
|
||||||
|
expect(persistedRoutes.length).toEqual(0);
|
||||||
|
expect(appliedRoutes.length).toEqual(2);
|
||||||
|
|
||||||
|
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 removes stale persisted DoH socket-handler routes on startup', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
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 staleResolveRoute = new RouteDoc();
|
||||||
|
staleResolveRoute.id = 'stale-doh-resolve';
|
||||||
|
staleResolveRoute.route = {
|
||||||
|
name: 'dns-over-https-resolve',
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
domains: ['ns1.example.com'],
|
||||||
|
path: '/resolve',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'socket-handler' as any,
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
staleResolveRoute.enabled = true;
|
||||||
|
staleResolveRoute.createdAt = Date.now();
|
||||||
|
staleResolveRoute.updatedAt = Date.now();
|
||||||
|
staleResolveRoute.createdBy = 'test';
|
||||||
|
staleResolveRoute.origin = 'dns';
|
||||||
|
await staleResolveRoute.save();
|
||||||
|
|
||||||
|
const validRoute = new RouteDoc();
|
||||||
|
validRoute.id = 'valid-forward-route';
|
||||||
|
validRoute.route = {
|
||||||
|
name: 'valid-forward-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;
|
||||||
|
validRoute.enabled = true;
|
||||||
|
validRoute.createdAt = Date.now();
|
||||||
|
validRoute.updatedAt = Date.now();
|
||||||
|
validRoute.createdBy = 'test';
|
||||||
|
validRoute.origin = 'api';
|
||||||
|
await validRoute.save();
|
||||||
|
|
||||||
|
const appliedRoutes: any[][] = [];
|
||||||
|
const smartProxy = {
|
||||||
|
updateRoutes: async (routes: any[]) => {
|
||||||
|
appliedRoutes.push(routes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeManager = new RouteConfigManager(() => smartProxy as any);
|
||||||
|
await routeManager.initialize([], [], []);
|
||||||
|
|
||||||
|
expect((await RouteDoc.findByName('dns-over-https-dns-query'))).toEqual(null);
|
||||||
|
expect((await RouteDoc.findByName('dns-over-https-resolve'))).toEqual(null);
|
||||||
|
|
||||||
|
const remainingRoutes = await RouteDoc.findAll();
|
||||||
|
expect(remainingRoutes.length).toEqual(1);
|
||||||
|
expect(remainingRoutes[0].route.name).toEqual('valid-forward-route');
|
||||||
|
|
||||||
|
expect(appliedRoutes.length).toEqual(1);
|
||||||
|
expect(appliedRoutes[0].length).toEqual(1);
|
||||||
|
expect(appliedRoutes[0][0].name).toEqual('valid-forward-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
const originalLog = logger.log.bind(logger);
|
||||||
|
const warningMessages: string[] = [];
|
||||||
|
|
||||||
|
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
|
||||||
|
if (level === 'warn') {
|
||||||
|
warningMessages.push(message);
|
||||||
|
}
|
||||||
|
return originalLog(level, message, context || {});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingDomain = new DomainDoc();
|
||||||
|
existingDomain.id = 'existing-domain';
|
||||||
|
existingDomain.name = 'example.com';
|
||||||
|
existingDomain.source = 'dcrouter';
|
||||||
|
existingDomain.authoritative = true;
|
||||||
|
existingDomain.createdAt = Date.now();
|
||||||
|
existingDomain.updatedAt = Date.now();
|
||||||
|
existingDomain.createdBy = 'test';
|
||||||
|
await existingDomain.save();
|
||||||
|
|
||||||
|
const dnsManager = new DnsManager({
|
||||||
|
dnsNsDomains: ['ns1.example.com'],
|
||||||
|
dnsScopes: ['example.com'],
|
||||||
|
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
|
||||||
|
smartProxyConfig: { routes: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await dnsManager.start();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
warningMessages.some((message) =>
|
||||||
|
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
|
||||||
|
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
|
||||||
|
),
|
||||||
|
).toEqual(true);
|
||||||
|
expect(
|
||||||
|
warningMessages.some((message) =>
|
||||||
|
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
|
||||||
|
),
|
||||||
|
).toEqual(false);
|
||||||
|
} finally {
|
||||||
|
(logger as any).log = originalLog;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup test db', async () => {
|
||||||
|
await clearTestState();
|
||||||
|
const testDb = await testDbPromise;
|
||||||
|
await testDb.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import { EmailDomainManager } from '../ts/email/index.js';
|
||||||
|
import { DcRouterDb, DomainDoc } from '../ts/db/index.js';
|
||||||
|
import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js';
|
||||||
|
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
const createTestDb = async () => {
|
||||||
|
const storagePath = plugins.path.join(
|
||||||
|
plugins.os.tmpdir(),
|
||||||
|
`dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
const db = DcRouterDb.getInstance({
|
||||||
|
storagePath,
|
||||||
|
dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
});
|
||||||
|
await db.start();
|
||||||
|
await db.getDb().mongoDb.createCollection('__test_init');
|
||||||
|
|
||||||
|
return {
|
||||||
|
async cleanup() {
|
||||||
|
await db.stop();
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const testDbPromise = createTestDb();
|
||||||
|
|
||||||
|
const clearTestState = async () => {
|
||||||
|
for (const emailDomain of await EmailDomainDoc.findAll()) {
|
||||||
|
await emailDomain.delete();
|
||||||
|
}
|
||||||
|
for (const domain of await DomainDoc.findAll()) {
|
||||||
|
await domain.delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => {
|
||||||
|
const doc = new DomainDoc();
|
||||||
|
doc.id = id;
|
||||||
|
doc.name = name;
|
||||||
|
doc.source = source;
|
||||||
|
doc.authoritative = source === 'dcrouter';
|
||||||
|
doc.createdAt = Date.now();
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
doc.createdBy = 'test';
|
||||||
|
await doc.save();
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({
|
||||||
|
ports: [2525],
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'static.example.com',
|
||||||
|
dnsMode: 'external-dns',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider');
|
||||||
|
const updateCalls: Array<{ domains?: any[] }> = [];
|
||||||
|
|
||||||
|
const dcRouterStub = {
|
||||||
|
options: {
|
||||||
|
emailConfig: createBaseEmailConfig(),
|
||||||
|
},
|
||||||
|
emailServer: {
|
||||||
|
updateOptions: (options: { domains?: any[] }) => {
|
||||||
|
updateCalls.push(options);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new EmailDomainManager(dcRouterStub);
|
||||||
|
await manager.start();
|
||||||
|
|
||||||
|
const created = await manager.createEmailDomain({
|
||||||
|
linkedDomainId: linkedDomain.id,
|
||||||
|
subdomain: 'mail',
|
||||||
|
dkimSelector: 'selector1',
|
||||||
|
rotateKeys: true,
|
||||||
|
rotationIntervalDays: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const domainsAfterCreate = dcRouterStub.options.emailConfig.domains;
|
||||||
|
expect(domainsAfterCreate.length).toEqual(2);
|
||||||
|
expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true);
|
||||||
|
|
||||||
|
const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com');
|
||||||
|
expect(managedDomain).toBeTruthy();
|
||||||
|
expect(managedDomain?.dnsMode).toEqual('external-dns');
|
||||||
|
expect(managedDomain?.dkim?.selector).toEqual('selector1');
|
||||||
|
expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true);
|
||||||
|
|
||||||
|
await manager.updateEmailDomain(created.id, {
|
||||||
|
rotateKeys: false,
|
||||||
|
rateLimits: {
|
||||||
|
outbound: {
|
||||||
|
messagesPerMinute: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains;
|
||||||
|
const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com');
|
||||||
|
expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false);
|
||||||
|
expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10);
|
||||||
|
|
||||||
|
await manager.deleteEmailDomain(created.id);
|
||||||
|
expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailDomainManager rejects domains already present in static config', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider');
|
||||||
|
const dcRouterStub = {
|
||||||
|
options: {
|
||||||
|
emailConfig: createBaseEmailConfig(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new EmailDomainManager(dcRouterStub);
|
||||||
|
|
||||||
|
let error: Error | undefined;
|
||||||
|
try {
|
||||||
|
await manager.createEmailDomain({ linkedDomainId: linkedDomain.id });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error = err as Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.message).toEqual('Email domain already configured for static.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter');
|
||||||
|
const stored = new EmailDomainDoc();
|
||||||
|
stored.id = 'managed-email-domain';
|
||||||
|
stored.domain = 'mail.managed.example.com';
|
||||||
|
stored.linkedDomainId = linkedDomain.id;
|
||||||
|
stored.subdomain = 'mail';
|
||||||
|
stored.dkim = {
|
||||||
|
selector: 'default',
|
||||||
|
keySize: 2048,
|
||||||
|
rotateKeys: false,
|
||||||
|
rotationIntervalDays: 90,
|
||||||
|
};
|
||||||
|
stored.dnsStatus = {
|
||||||
|
mx: 'unchecked',
|
||||||
|
spf: 'unchecked',
|
||||||
|
dkim: 'unchecked',
|
||||||
|
dmarc: 'unchecked',
|
||||||
|
};
|
||||||
|
stored.createdAt = new Date().toISOString();
|
||||||
|
stored.updatedAt = new Date().toISOString();
|
||||||
|
await stored.save();
|
||||||
|
|
||||||
|
const dcRouterStub = {
|
||||||
|
options: {
|
||||||
|
emailConfig: createBaseEmailConfig(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new EmailDomainManager(dcRouterStub);
|
||||||
|
await manager.start();
|
||||||
|
|
||||||
|
const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com');
|
||||||
|
expect(managedDomain?.dnsMode).toEqual('internal-dns');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
const testDb = await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
await testDb.cleanup();
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
|
||||||
|
|
||||||
|
const emptyProtocolDistribution = {
|
||||||
|
h1Active: 0,
|
||||||
|
h1Total: 0,
|
||||||
|
h2Active: 0,
|
||||||
|
h2Total: 0,
|
||||||
|
h3Active: 0,
|
||||||
|
h3Total: 0,
|
||||||
|
wsActive: 0,
|
||||||
|
wsTotal: 0,
|
||||||
|
otherActive: 0,
|
||||||
|
otherTotal: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createProxyMetrics(args: {
|
||||||
|
connectionsByRoute: Map<string, number>;
|
||||||
|
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||||
|
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||||
|
requestsTotal?: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
connections: {
|
||||||
|
active: () => 0,
|
||||||
|
total: () => 0,
|
||||||
|
byRoute: () => args.connectionsByRoute,
|
||||||
|
byIP: () => new Map<string, number>(),
|
||||||
|
topIPs: () => [],
|
||||||
|
domainRequestsByIP: () => args.domainRequestsByIP,
|
||||||
|
topDomainRequests: () => [],
|
||||||
|
frontendProtocols: () => emptyProtocolDistribution,
|
||||||
|
backendProtocols: () => emptyProtocolDistribution,
|
||||||
|
},
|
||||||
|
throughput: {
|
||||||
|
instant: () => ({ in: 0, out: 0 }),
|
||||||
|
recent: () => ({ in: 0, out: 0 }),
|
||||||
|
average: () => ({ in: 0, out: 0 }),
|
||||||
|
custom: () => ({ in: 0, out: 0 }),
|
||||||
|
history: () => [],
|
||||||
|
byRoute: () => args.throughputByRoute,
|
||||||
|
byIP: () => new Map<string, { in: number; out: number }>(),
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
perSecond: () => 0,
|
||||||
|
perMinute: () => 0,
|
||||||
|
total: () => args.requestsTotal || 0,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
bytesIn: () => 0,
|
||||||
|
bytesOut: () => 0,
|
||||||
|
connections: () => 0,
|
||||||
|
},
|
||||||
|
backends: {
|
||||||
|
byBackend: () => new Map<string, any>(),
|
||||||
|
protocols: () => new Map<string, string>(),
|
||||||
|
topByErrors: () => [],
|
||||||
|
detectedProtocols: () => [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
|
||||||
|
const proxyMetrics = createProxyMetrics({
|
||||||
|
connectionsByRoute: new Map([
|
||||||
|
['route-id-only', 4],
|
||||||
|
]),
|
||||||
|
throughputByRoute: new Map([
|
||||||
|
['route-id-only', { in: 1200, out: 2400 }],
|
||||||
|
]),
|
||||||
|
domainRequestsByIP: new Map([
|
||||||
|
['192.0.2.10', new Map([
|
||||||
|
['alpha.example.com', 3],
|
||||||
|
['beta.example.com', 1],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
requestsTotal: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const smartProxy = {
|
||||||
|
getMetrics: () => proxyMetrics,
|
||||||
|
routeManager: {
|
||||||
|
getRoutes: () => [
|
||||||
|
{
|
||||||
|
id: 'route-id-only',
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
domains: ['alpha.example.com', 'beta.example.com'],
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new MetricsManager({ smartProxy } as any);
|
||||||
|
const stats = await manager.getNetworkStats();
|
||||||
|
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
|
||||||
|
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
|
||||||
|
|
||||||
|
expect(alpha).toBeDefined();
|
||||||
|
expect(beta).toBeDefined();
|
||||||
|
|
||||||
|
expect(alpha!.requestCount).toEqual(3);
|
||||||
|
expect(alpha!.routeCount).toEqual(1);
|
||||||
|
expect(alpha!.activeConnections).toEqual(3);
|
||||||
|
expect(alpha!.bytesInPerSecond).toEqual(900);
|
||||||
|
expect(alpha!.bytesOutPerSecond).toEqual(1800);
|
||||||
|
|
||||||
|
expect(beta!.requestCount).toEqual(1);
|
||||||
|
expect(beta!.routeCount).toEqual(1);
|
||||||
|
expect(beta!.activeConnections).toEqual(1);
|
||||||
|
expect(beta!.bytesInPerSecond).toEqual(300);
|
||||||
|
expect(beta!.bytesOutPerSecond).toEqual(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.17.5',
|
version: '13.18.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-87
@@ -9,6 +9,7 @@ import {
|
|||||||
type IUnifiedEmailServerOptions,
|
type IUnifiedEmailServerOptions,
|
||||||
type IEmailRoute,
|
type IEmailRoute,
|
||||||
type IEmailDomainConfig,
|
type IEmailDomainConfig,
|
||||||
|
type IStorageManagerLike,
|
||||||
} from '@push.rocks/smartmta';
|
} from '@push.rocks/smartmta';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
||||||
@@ -29,7 +30,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
|
|||||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
import { DnsManager } from './dns/manager.dns.js';
|
import { DnsManager } from './dns/manager.dns.js';
|
||||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||||
import { EmailDomainManager } from './email/classes.email-domain.manager.js';
|
import { EmailDomainManager, SmartMtaStorageManager } from './email/index.js';
|
||||||
|
|
||||||
export interface IDcRouterOptions {
|
export interface IDcRouterOptions {
|
||||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||||
@@ -248,15 +249,13 @@ export class DcRouter {
|
|||||||
public radiusServer?: RadiusServer;
|
public radiusServer?: RadiusServer;
|
||||||
public opsServer!: OpsServer;
|
public opsServer!: OpsServer;
|
||||||
public metricsManager?: MetricsManager;
|
public metricsManager?: MetricsManager;
|
||||||
|
private emailEventSubscriptions: Array<{
|
||||||
|
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
|
||||||
|
eventName: string;
|
||||||
|
listener: (...args: any[]) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set()
|
public storageManager: IStorageManagerLike;
|
||||||
public storageManager: any = {
|
|
||||||
get: async (_key: string) => null,
|
|
||||||
set: async (_key: string, _value: string) => {
|
|
||||||
// DKIM keys from smartmta — logged but not yet migrated to smartdata
|
|
||||||
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
||||||
public dcRouterDb?: DcRouterDb;
|
public dcRouterDb?: DcRouterDb;
|
||||||
@@ -315,7 +314,8 @@ export class DcRouter {
|
|||||||
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
||||||
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
// Runtime-only DoH routes. These carry live socket handlers and must never be persisted.
|
||||||
|
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
// Environment access
|
// Environment access
|
||||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||||
@@ -328,6 +328,10 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Resolve all data paths from baseDir
|
// Resolve all data paths from baseDir
|
||||||
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
||||||
|
paths.ensureDataDirectories(this.resolvedPaths);
|
||||||
|
this.storageManager = new SmartMtaStorageManager(
|
||||||
|
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize service manager and register all services
|
// Initialize service manager and register all services
|
||||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||||
@@ -451,9 +455,13 @@ export class DcRouter {
|
|||||||
.dependsOn('DcRouterDb')
|
.dependsOn('DcRouterDb')
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
this.emailDomainManager = new EmailDomainManager(this);
|
this.emailDomainManager = new EmailDomainManager(this);
|
||||||
|
await this.emailDomainManager.start();
|
||||||
})
|
})
|
||||||
.withStop(async () => {
|
.withStop(async () => {
|
||||||
|
if (this.emailDomainManager) {
|
||||||
|
await this.emailDomainManager.stop();
|
||||||
this.emailDomainManager = undefined;
|
this.emailDomainManager = undefined;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -580,13 +588,13 @@ export class DcRouter {
|
|||||||
this.tunnelManager.syncAllowedEdges();
|
this.tunnelManager.syncAllowedEdges();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
() => this.runtimeDnsRoutes,
|
||||||
);
|
);
|
||||||
this.apiTokenManager = new ApiTokenManager();
|
this.apiTokenManager = new ApiTokenManager();
|
||||||
await this.apiTokenManager.initialize();
|
await this.apiTokenManager.initialize();
|
||||||
await this.routeConfigManager.initialize(
|
await this.routeConfigManager.initialize(
|
||||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
|
||||||
);
|
);
|
||||||
await this.targetProfileManager.normalizeAllRouteRefs();
|
await this.targetProfileManager.normalizeAllRouteRefs();
|
||||||
|
|
||||||
@@ -609,19 +617,20 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Email Server: optional, depends on SmartProxy
|
// Email Server: optional, depends on SmartProxy
|
||||||
if (this.options.emailConfig) {
|
if (this.options.emailConfig) {
|
||||||
|
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
|
||||||
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
|
emailServiceDeps.push('EmailDomainManager');
|
||||||
|
}
|
||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('EmailServer')
|
new plugins.taskbuffer.Service('EmailServer')
|
||||||
.optional()
|
.optional()
|
||||||
.dependsOn('SmartProxy')
|
.dependsOn(...emailServiceDeps)
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
await this.setupUnifiedEmailHandling();
|
await this.setupUnifiedEmailHandling();
|
||||||
})
|
})
|
||||||
.withStop(async () => {
|
.withStop(async () => {
|
||||||
if (this.emailServer) {
|
if (this.emailServer) {
|
||||||
if ((this.emailServer as any).deliverySystem) {
|
this.clearEmailEventSubscriptions();
|
||||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
|
||||||
}
|
|
||||||
this.emailServer.removeAllListeners();
|
|
||||||
await this.emailServer.stop();
|
await this.emailServer.stop();
|
||||||
this.emailServer = undefined;
|
this.emailServer = undefined;
|
||||||
}
|
}
|
||||||
@@ -635,7 +644,7 @@ export class DcRouter {
|
|||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('DnsServer')
|
new plugins.taskbuffer.Service('DnsServer')
|
||||||
.optional()
|
.optional()
|
||||||
.dependsOn('SmartProxy')
|
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
await this.setupDnsWithSocketHandler();
|
await this.setupDnsWithSocketHandler();
|
||||||
})
|
})
|
||||||
@@ -892,7 +901,7 @@ export class DcRouter {
|
|||||||
this.smartProxy = undefined;
|
this.smartProxy = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble seed routes from constructor config — these will be seeded into DB
|
// Assemble serializable seed routes from constructor config — these will be seeded into DB
|
||||||
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
|
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
|
||||||
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
|
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
|
||||||
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
|
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
|
||||||
@@ -903,17 +912,17 @@ export class DcRouter {
|
|||||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.seedDnsRoutes = [];
|
this.runtimeDnsRoutes = [];
|
||||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||||
this.seedDnsRoutes = this.generateDnsRoutes();
|
this.runtimeDnsRoutes = this.generateDnsRoutes();
|
||||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
|
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
||||||
let routes: plugins.smartproxy.IRouteConfig[] = [
|
let routes: plugins.smartproxy.IRouteConfig[] = [
|
||||||
...this.seedConfigRoutes,
|
...this.seedConfigRoutes,
|
||||||
...this.seedEmailRoutes,
|
...this.seedEmailRoutes,
|
||||||
...this.seedDnsRoutes,
|
...this.runtimeDnsRoutes,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
||||||
@@ -1463,7 +1472,6 @@ export class DcRouter {
|
|||||||
await this.routeConfigManager.initialize(
|
await this.routeConfigManager.initialize(
|
||||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,40 +1519,74 @@ export class DcRouter {
|
|||||||
...this.options.emailConfig,
|
...this.options.emailConfig,
|
||||||
domains: transformedDomains,
|
domains: transformedDomains,
|
||||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
||||||
hostname: 'localhost' // Listen on localhost for SmartProxy forwarding
|
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
|
||||||
|
queue: {
|
||||||
|
storageType: 'disk',
|
||||||
|
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
||||||
|
...this.options.emailConfig.queue,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create unified email server
|
// Create unified email server
|
||||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||||
|
this.clearEmailEventSubscriptions();
|
||||||
|
|
||||||
// Set up error handling
|
// Set up error handling
|
||||||
this.emailServer.on('error', (err: Error) => {
|
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
|
||||||
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
await this.emailServer.start();
|
await this.emailServer.start();
|
||||||
|
|
||||||
// Wire delivery events to MetricsManager and logger
|
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
|
||||||
if (this.metricsManager && this.emailServer.deliverySystem) {
|
|
||||||
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
|
|
||||||
this.metricsManager!.trackEmailReceived(item?.from);
|
|
||||||
logger.log('info', `Email delivery started: ${item?.from} → ${item?.to}`, { zone: 'email' });
|
|
||||||
});
|
|
||||||
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
|
|
||||||
this.metricsManager!.trackEmailSent(item?.to);
|
|
||||||
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
|
|
||||||
});
|
|
||||||
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
|
|
||||||
this.metricsManager!.trackEmailFailed(item?.to, error?.message);
|
|
||||||
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.metricsManager && this.emailServer) {
|
if (this.metricsManager && this.emailServer) {
|
||||||
this.emailServer.on('bounceProcessed', () => {
|
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
|
||||||
|
const emailLike = item?.processingResult;
|
||||||
|
const from = emailLike?.from || emailLike?.email?.from || '';
|
||||||
|
const recipients = Array.isArray(emailLike?.to)
|
||||||
|
? emailLike.to
|
||||||
|
: Array.isArray(emailLike?.email?.to)
|
||||||
|
? emailLike.email.to
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
recipients: recipients.filter(Boolean),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const updateQueueSize = () => {
|
||||||
|
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
||||||
|
const envelope = getEnvelope(item);
|
||||||
|
this.metricsManager!.trackEmailReceived(envelope.from);
|
||||||
|
updateQueueSize();
|
||||||
|
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
|
||||||
|
const envelope = getEnvelope(item);
|
||||||
|
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
|
||||||
|
updateQueueSize();
|
||||||
|
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
|
||||||
|
const envelope = getEnvelope(item);
|
||||||
|
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
|
||||||
|
updateQueueSize();
|
||||||
|
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
|
||||||
|
updateQueueSize();
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
|
||||||
|
updateQueueSize();
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
|
||||||
this.metricsManager!.trackEmailBounced();
|
this.metricsManager!.trackEmailBounced();
|
||||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||||
});
|
});
|
||||||
|
updateQueueSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
||||||
@@ -1574,11 +1616,7 @@ export class DcRouter {
|
|||||||
try {
|
try {
|
||||||
// Stop the unified email server which contains all components
|
// Stop the unified email server which contains all components
|
||||||
if (this.emailServer) {
|
if (this.emailServer) {
|
||||||
// Remove listeners before stopping to prevent leaks on config update cycles
|
this.clearEmailEventSubscriptions();
|
||||||
if ((this.emailServer as any).deliverySystem) {
|
|
||||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
|
||||||
}
|
|
||||||
this.emailServer.removeAllListeners();
|
|
||||||
await this.emailServer.stop();
|
await this.emailServer.stop();
|
||||||
logger.log('info', 'Unified email server stopped');
|
logger.log('info', 'Unified email server stopped');
|
||||||
this.emailServer = undefined;
|
this.emailServer = undefined;
|
||||||
@@ -1786,10 +1824,10 @@ export class DcRouter {
|
|||||||
// Generate email DNS records
|
// Generate email DNS records
|
||||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||||
|
|
||||||
// Initialize DKIM for all email domains
|
// Ensure DKIM keys exist for internal-dns domains before generating records.
|
||||||
await this.initializeDkimForEmailDomains();
|
await this.initializeDkimForEmailDomains();
|
||||||
|
|
||||||
// Load DKIM records from JSON files (they should now exist)
|
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
|
||||||
const dkimRecords = await this.loadDkimRecords();
|
const dkimRecords = await this.loadDkimRecords();
|
||||||
|
|
||||||
// Combine all records: authoritative, email, DKIM, and user-defined
|
// Combine all records: authoritative, email, DKIM, and user-defined
|
||||||
@@ -1939,55 +1977,31 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load DKIM records from JSON files
|
* Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
|
||||||
* Reads all *.dkimrecord.json files from the DNS records directory
|
|
||||||
*/
|
*/
|
||||||
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
||||||
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
||||||
|
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
|
||||||
try {
|
|
||||||
// Ensure paths are imported
|
|
||||||
const dnsDir = this.resolvedPaths.dnsRecordsDir;
|
|
||||||
|
|
||||||
// Check if directory exists
|
|
||||||
if (!plugins.fs.existsSync(dnsDir)) {
|
|
||||||
logger.log('debug', 'No DNS records directory found, skipping DKIM record loading');
|
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read all files in the directory
|
for (const domainConfig of this.options.emailConfig.domains) {
|
||||||
const files = plugins.fs.readdirSync(dnsDir);
|
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||||
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
|
continue;
|
||||||
|
}
|
||||||
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
|
const selector = domainConfig.dkim?.selector || 'default';
|
||||||
|
|
||||||
// Load each DKIM record
|
|
||||||
for (const file of dkimFiles) {
|
|
||||||
try {
|
try {
|
||||||
const filePath = plugins.path.join(dnsDir, file);
|
const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
|
||||||
const fileContent = plugins.fs.readFileSync(filePath, 'utf8');
|
|
||||||
const dkimRecord = JSON.parse(fileContent);
|
|
||||||
|
|
||||||
// Validate record structure
|
|
||||||
if (dkimRecord.name && dkimRecord.type === 'TXT' && dkimRecord.value) {
|
|
||||||
records.push({
|
records.push({
|
||||||
name: dkimRecord.name,
|
name: dkimRecord.name,
|
||||||
type: 'TXT',
|
type: 'TXT',
|
||||||
value: dkimRecord.value,
|
value: dkimRecord.value,
|
||||||
ttl: 3600 // Standard DKIM TTL
|
ttl: domainConfig.dns?.internal?.ttl || 3600,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
|
|
||||||
} else {
|
|
||||||
logger.log('warn', `Invalid DKIM record structure in ${file}`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
|
logger.log('error', `Failed to generate DKIM record for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
@@ -2013,12 +2027,17 @@ export class DcRouter {
|
|||||||
// Ensure necessary directories exist
|
// Ensure necessary directories exist
|
||||||
paths.ensureDataDirectories(this.resolvedPaths);
|
paths.ensureDataDirectories(this.resolvedPaths);
|
||||||
|
|
||||||
// Generate DKIM keys for each email domain
|
// Generate DKIM keys for each internal-dns email domain using the configured selector.
|
||||||
for (const domainConfig of this.options.emailConfig.domains) {
|
for (const domainConfig of this.options.emailConfig.domains) {
|
||||||
|
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Generate DKIM keys for all domains, regardless of DNS mode
|
await dkimCreator.handleDKIMKeysForSelector(
|
||||||
// This ensures keys are ready even if DNS mode changes later
|
domainConfig.domain,
|
||||||
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
|
domainConfig.dkim?.selector || 'default',
|
||||||
|
domainConfig.dkim?.keySize || 2048,
|
||||||
|
);
|
||||||
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||||
@@ -2149,6 +2168,25 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addEmailEventSubscription(
|
||||||
|
emitter: {
|
||||||
|
on(eventName: string, listener: (...args: any[]) => void): void;
|
||||||
|
off(eventName: string, listener: (...args: any[]) => void): void;
|
||||||
|
},
|
||||||
|
eventName: string,
|
||||||
|
listener: (...args: any[]) => void,
|
||||||
|
): void {
|
||||||
|
emitter.on(eventName, listener);
|
||||||
|
this.emailEventSubscriptions.push({ emitter, eventName, listener });
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearEmailEventSubscriptions(): void {
|
||||||
|
for (const subscription of this.emailEventSubscriptions) {
|
||||||
|
subscription.emitter.off(subscription.eventName, subscription.listener);
|
||||||
|
}
|
||||||
|
this.emailEventSubscriptions = [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect the server's public IP address
|
* Detect the server's public IP address
|
||||||
*/
|
*/
|
||||||
@@ -2185,7 +2223,7 @@ export class DcRouter {
|
|||||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||||
// will push the complete merged routes here.
|
// will push the complete merged routes here.
|
||||||
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.seedDnsRoutes];
|
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
|
||||||
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
||||||
|
|
||||||
// If ConfigManagers finished before us, re-apply routes
|
// If ConfigManagers finished before us, re-apply routes
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export class RouteConfigManager {
|
|||||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||||
private referenceResolver?: ReferenceResolver,
|
private referenceResolver?: ReferenceResolver,
|
||||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||||
|
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Expose routes map for reference resolution lookups. */
|
/** Expose routes map for reference resolution lookups. */
|
||||||
@@ -63,7 +64,8 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load persisted routes, seed config/email/dns routes, compute warnings, apply to SmartProxy.
|
* Load persisted routes, seed serializable config/email/dns routes,
|
||||||
|
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||||
*/
|
*/
|
||||||
public async initialize(
|
public async initialize(
|
||||||
configRoutes: IDcRouterRouteConfig[] = [],
|
configRoutes: IDcRouterRouteConfig[] = [],
|
||||||
@@ -284,9 +286,12 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
private async loadRoutes(): Promise<void> {
|
private async loadRoutes(): Promise<void> {
|
||||||
const docs = await RouteDoc.findAll();
|
const docs = await RouteDoc.findAll();
|
||||||
|
let prunedRuntimeRoutes = 0;
|
||||||
|
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
if (doc.id) {
|
if (!doc.id) continue;
|
||||||
this.routes.set(doc.id, {
|
|
||||||
|
const storedRoute: IRoute = {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
route: doc.route,
|
route: doc.route,
|
||||||
enabled: doc.enabled,
|
enabled: doc.enabled,
|
||||||
@@ -295,12 +300,26 @@ export class RouteConfigManager {
|
|||||||
createdBy: doc.createdBy,
|
createdBy: doc.createdBy,
|
||||||
origin: doc.origin || 'api',
|
origin: doc.origin || 'api',
|
||||||
metadata: doc.metadata,
|
metadata: doc.metadata,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (this.isPersistedRuntimeRoute(storedRoute)) {
|
||||||
|
await doc.delete();
|
||||||
|
prunedRuntimeRoutes++;
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.routes.set(doc.id, storedRoute);
|
||||||
}
|
}
|
||||||
if (this.routes.size > 0) {
|
if (this.routes.size > 0) {
|
||||||
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
||||||
}
|
}
|
||||||
|
if (prunedRuntimeRoutes > 0) {
|
||||||
|
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistRoute(stored: IRoute): Promise<void> {
|
private async persistRoute(stored: IRoute): Promise<void> {
|
||||||
@@ -389,34 +408,16 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
const http3Config = this.getHttp3Config?.();
|
|
||||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
|
||||||
|
|
||||||
// Helper: inject VPN security into a vpnOnly route
|
|
||||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
|
||||||
if (!vpnCallback) return route;
|
|
||||||
const dcRoute = route as IDcRouterRouteConfig;
|
|
||||||
if (!dcRoute.vpnOnly) return route;
|
|
||||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
|
||||||
const existingEntries = route.security?.ipAllowList || [];
|
|
||||||
return {
|
|
||||||
...route,
|
|
||||||
security: {
|
|
||||||
...route.security,
|
|
||||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||||
for (const route of this.routes.values()) {
|
for (const route of this.routes.values()) {
|
||||||
if (route.enabled) {
|
if (route.enabled) {
|
||||||
let r = route.route;
|
enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
|
||||||
if (http3Config?.enabled !== false) {
|
|
||||||
r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config });
|
|
||||||
}
|
}
|
||||||
enabledRoutes.push(injectVpn(r, route.id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||||
|
for (const route of runtimeRoutes) {
|
||||||
|
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||||
}
|
}
|
||||||
|
|
||||||
await smartProxy.updateRoutes(enabledRoutes);
|
await smartProxy.updateRoutes(enabledRoutes);
|
||||||
@@ -429,4 +430,47 @@ export class RouteConfigManager {
|
|||||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private prepareRouteForApply(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
routeId?: string,
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
let preparedRoute = route;
|
||||||
|
const http3Config = this.getHttp3Config?.();
|
||||||
|
|
||||||
|
if (http3Config?.enabled !== false) {
|
||||||
|
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.injectVpnSecurity(preparedRoute, routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private injectVpnSecurity(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
routeId?: string,
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||||
|
if (!vpnCallback) return route;
|
||||||
|
|
||||||
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
|
if (!dcRoute.vpnOnly) return route;
|
||||||
|
|
||||||
|
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||||
|
const existingEntries = route.security?.ipAllowList || [];
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
|
||||||
|
const routeName = storedRoute.route.name || '';
|
||||||
|
const actionType = storedRoute.route.action?.type;
|
||||||
|
|
||||||
|
return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
|
||||||
|
|| (storedRoute.origin === 'dns' && actionType === 'socket-handler');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ export class DnsManager {
|
|||||||
if (hasLegacyConfig) {
|
if (hasLegacyConfig) {
|
||||||
logger.log(
|
logger.log(
|
||||||
'warn',
|
'warn',
|
||||||
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
|
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
|
||||||
'Manage DNS via the Domains UI instead.',
|
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
||||||
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
||||||
@@ -15,9 +16,12 @@ import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_i
|
|||||||
*/
|
*/
|
||||||
export class EmailDomainManager {
|
export class EmailDomainManager {
|
||||||
private dcRouter: any; // DcRouter — avoids circular import
|
private dcRouter: any; // DcRouter — avoids circular import
|
||||||
|
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||||
|
|
||||||
constructor(dcRouterRef: any) {
|
constructor(dcRouterRef: any) {
|
||||||
this.dcRouter = dcRouterRef;
|
this.dcRouter = dcRouterRef;
|
||||||
|
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||||
|
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get dnsManager(): DnsManager | undefined {
|
private get dnsManager(): DnsManager | undefined {
|
||||||
@@ -32,6 +36,12 @@ export class EmailDomainManager {
|
|||||||
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
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
|
// CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -64,6 +74,9 @@ export class EmailDomainManager {
|
|||||||
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
|
if (this.isDomainAlreadyConfigured(domainName)) {
|
||||||
|
throw new Error(`Email domain already configured for ${domainName}`);
|
||||||
|
}
|
||||||
const existing = await EmailDomainDoc.findByDomain(domainName);
|
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new Error(`Email domain already exists for ${domainName}`);
|
throw new Error(`Email domain already exists for ${domainName}`);
|
||||||
@@ -77,8 +90,8 @@ export class EmailDomainManager {
|
|||||||
let publicKey: string | undefined;
|
let publicKey: string | undefined;
|
||||||
if (this.dkimCreator) {
|
if (this.dkimCreator) {
|
||||||
try {
|
try {
|
||||||
await this.dkimCreator.handleDKIMKeysForDomain(domainName);
|
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
||||||
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
|
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
||||||
// Extract public key from the DNS record value
|
// Extract public key from the DNS record value
|
||||||
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
||||||
publicKey = match ? match[1] : undefined;
|
publicKey = match ? match[1] : undefined;
|
||||||
@@ -110,6 +123,7 @@ export class EmailDomainManager {
|
|||||||
doc.createdAt = now;
|
doc.createdAt = now;
|
||||||
doc.updatedAt = now;
|
doc.updatedAt = now;
|
||||||
await doc.save();
|
await doc.save();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
|
|
||||||
logger.log('info', `Email domain created: ${domainName}`);
|
logger.log('info', `Email domain created: ${domainName}`);
|
||||||
return this.docToInterface(doc);
|
return this.docToInterface(doc);
|
||||||
@@ -131,12 +145,14 @@ export class EmailDomainManager {
|
|||||||
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
||||||
doc.updatedAt = new Date().toISOString();
|
doc.updatedAt = new Date().toISOString();
|
||||||
await doc.save();
|
await doc.save();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteEmailDomain(id: string): Promise<void> {
|
public async deleteEmailDomain(id: string): Promise<void> {
|
||||||
const doc = await EmailDomainDoc.findById(id);
|
const doc = await EmailDomainDoc.findById(id);
|
||||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||||
await doc.delete();
|
await doc.delete();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +169,17 @@ export class EmailDomainManager {
|
|||||||
|
|
||||||
const domain = doc.domain;
|
const domain = doc.domain;
|
||||||
const selector = doc.dkim.selector;
|
const selector = doc.dkim.selector;
|
||||||
const publicKey = doc.dkim.publicKey || '';
|
|
||||||
const hostname = this.emailHostname;
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const records: IEmailDnsRecord[] = [
|
const records: IEmailDnsRecord[] = [
|
||||||
{
|
{
|
||||||
@@ -172,7 +197,7 @@ export class EmailDomainManager {
|
|||||||
{
|
{
|
||||||
type: 'TXT',
|
type: 'TXT',
|
||||||
name: `${selector}._domainkey.${domain}`,
|
name: `${selector}._domainkey.${domain}`,
|
||||||
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
|
value: dkimValue,
|
||||||
status: doc.dnsStatus.dkim,
|
status: doc.dnsStatus.dkim,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -207,17 +232,7 @@ export class EmailDomainManager {
|
|||||||
|
|
||||||
for (const required of requiredRecords) {
|
for (const required of requiredRecords) {
|
||||||
// Check if a matching record already exists
|
// Check if a matching record already exists
|
||||||
const exists = existingRecords.some((r) => {
|
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
||||||
if (required.type === 'MX') {
|
|
||||||
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
|
|
||||||
}
|
|
||||||
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
|
|
||||||
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
|
|
||||||
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
|
|
||||||
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
|
|
||||||
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
try {
|
try {
|
||||||
@@ -259,16 +274,23 @@ export class EmailDomainManager {
|
|||||||
const resolver = new plugins.dns.promises.Resolver();
|
const resolver = new plugins.dns.promises.Resolver();
|
||||||
|
|
||||||
// MX check
|
// MX check
|
||||||
doc.dnsStatus.mx = await this.checkMx(resolver, domain);
|
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
|
// SPF check
|
||||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
|
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
||||||
|
|
||||||
// DKIM check
|
// DKIM check
|
||||||
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1');
|
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
||||||
|
|
||||||
// DMARC check
|
// DMARC check
|
||||||
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1');
|
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
|
||||||
|
|
||||||
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||||
doc.updatedAt = new Date().toISOString();
|
doc.updatedAt = new Date().toISOString();
|
||||||
@@ -277,10 +299,28 @@ export class EmailDomainManager {
|
|||||||
return this.getRequiredDnsRecords(id);
|
return this.getRequiredDnsRecords(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
|
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 {
|
try {
|
||||||
const records = await resolver.resolveMx(domain);
|
const records = await resolver.resolveMx(domain);
|
||||||
return records && records.length > 0 ? 'valid' : 'missing';
|
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 {
|
} catch {
|
||||||
return 'missing';
|
return 'missing';
|
||||||
}
|
}
|
||||||
@@ -289,13 +329,19 @@ export class EmailDomainManager {
|
|||||||
private async checkTxtRecord(
|
private async checkTxtRecord(
|
||||||
resolver: plugins.dns.promises.Resolver,
|
resolver: plugins.dns.promises.Resolver,
|
||||||
name: string,
|
name: string,
|
||||||
prefix: string,
|
expectedValue?: string,
|
||||||
): Promise<TDnsRecordStatus> {
|
): Promise<TDnsRecordStatus> {
|
||||||
try {
|
try {
|
||||||
const records = await resolver.resolveTxt(name);
|
const records = await resolver.resolveTxt(name);
|
||||||
const flat = records.map((r) => r.join(''));
|
const flat = records.map((r) => r.join(''));
|
||||||
const found = flat.some((r) => r.startsWith(prefix));
|
if (flat.length === 0) {
|
||||||
return found ? 'valid' : 'missing';
|
return 'missing';
|
||||||
|
}
|
||||||
|
if (!expectedValue) {
|
||||||
|
return 'valid';
|
||||||
|
}
|
||||||
|
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
||||||
|
return found ? 'valid' : 'invalid';
|
||||||
} catch {
|
} catch {
|
||||||
return 'missing';
|
return 'missing';
|
||||||
}
|
}
|
||||||
@@ -318,4 +364,63 @@ export class EmailDomainManager {
|
|||||||
updatedAt: doc.updatedAt,
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncManagedDomainsToRuntime(): Promise<void> {
|
||||||
|
if (!this.dcRouter.options?.emailConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
||||||
|
for (const domainConfig of this.baseEmailDomains) {
|
||||||
|
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const managedConfig of await this.buildManagedDomainConfigs()) {
|
||||||
|
const key = managedConfig.domain.toLowerCase();
|
||||||
|
if (mergedDomains.has(key)) {
|
||||||
|
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mergedDomains.set(key, managedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = Array.from(mergedDomains.values());
|
||||||
|
this.dcRouter.options.emailConfig.domains = domains;
|
||||||
|
if (this.dcRouter.emailServer) {
|
||||||
|
this.dcRouter.emailServer.updateOptions({ domains });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IStorageManagerLike } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
export class SmartMtaStorageManager implements IStorageManagerLike {
|
||||||
|
private readonly resolvedRootDir: string;
|
||||||
|
|
||||||
|
constructor(private rootDir: string) {
|
||||||
|
this.resolvedRootDir = plugins.path.resolve(rootDir);
|
||||||
|
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKey(key: string): string {
|
||||||
|
return key.replace(/^\/+/, '').replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePathForKey(key: string): string {
|
||||||
|
const normalizedKey = this.normalizeKey(key);
|
||||||
|
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
|
||||||
|
if (
|
||||||
|
resolvedPath !== this.resolvedRootDir
|
||||||
|
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
|
||||||
|
) {
|
||||||
|
throw new Error(`Storage key escapes root directory: ${key}`);
|
||||||
|
}
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStorageKey(filePath: string): string {
|
||||||
|
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
|
||||||
|
return `/${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(key: string): Promise<string | null> {
|
||||||
|
const filePath = this.resolvePathForKey(key);
|
||||||
|
try {
|
||||||
|
return await plugins.fs.promises.readFile(filePath, 'utf8');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(key: string, value: string): Promise<void> {
|
||||||
|
const filePath = this.resolvePathForKey(key);
|
||||||
|
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
|
||||||
|
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(prefix: string): Promise<string[]> {
|
||||||
|
const prefixPath = this.resolvePathForKey(prefix);
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(prefixPath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
return [this.toStorageKey(prefixPath)];
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: string[] = [];
|
||||||
|
const walk = async (currentPath: string): Promise<void> => {
|
||||||
|
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = plugins.path.join(currentPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await walk(entryPath);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
results.push(this.toStorageKey(entryPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await walk(prefixPath);
|
||||||
|
return results.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(key: string): Promise<void> {
|
||||||
|
const targetPath = this.resolvePathForKey(key);
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(targetPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
await plugins.fs.promises.unlink(targetPath);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDir = plugins.path.dirname(targetPath);
|
||||||
|
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
|
||||||
|
const entries = await plugins.fs.promises.readdir(currentDir);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await plugins.fs.promises.rmdir(currentDir);
|
||||||
|
currentDir = plugins.path.dirname(currentDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './classes.email-domain.manager.js';
|
export * from './classes.email-domain.manager.js';
|
||||||
|
export * from './classes.smartmta-storage-manager.js';
|
||||||
|
|||||||
@@ -733,16 +733,17 @@ export class MetricsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map route name → domains from route config
|
// Map canonical route key → domains from route config
|
||||||
const routeDomains = new Map<string, string[]>();
|
const routeDomains = new Map<string, string[]>();
|
||||||
if (this.dcRouter.smartProxy) {
|
if (this.dcRouter.smartProxy) {
|
||||||
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
||||||
if (!route.name || !route.match.domains) continue;
|
const routeKey = route.name || route.id;
|
||||||
|
if (!routeKey || !route.match.domains) continue;
|
||||||
const domains = Array.isArray(route.match.domains)
|
const domains = Array.isArray(route.match.domains)
|
||||||
? route.match.domains
|
? route.match.domains
|
||||||
: [route.match.domains];
|
: [route.match.domains];
|
||||||
if (domains.length > 0) {
|
if (domains.length > 0) {
|
||||||
routeDomains.set(route.name, domains);
|
routeDomains.set(routeKey, domains);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -753,23 +754,23 @@ export class MetricsManager {
|
|||||||
if (entry.domain) allKnownDomains.add(entry.domain);
|
if (entry.domain) allKnownDomains.add(entry.domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reverse map: concrete domain → route name(s)
|
// Build reverse map: concrete domain → canonical route key(s)
|
||||||
const domainToRoutes = new Map<string, string[]>();
|
const domainToRoutes = new Map<string, string[]>();
|
||||||
for (const [routeName, domains] of routeDomains) {
|
for (const [routeKey, domains] of routeDomains) {
|
||||||
for (const pattern of domains) {
|
for (const pattern of domains) {
|
||||||
if (pattern.includes('*')) {
|
if (pattern.includes('*')) {
|
||||||
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
||||||
for (const knownDomain of allKnownDomains) {
|
for (const knownDomain of allKnownDomains) {
|
||||||
if (regex.test(knownDomain)) {
|
if (regex.test(knownDomain)) {
|
||||||
const existing = domainToRoutes.get(knownDomain);
|
const existing = domainToRoutes.get(knownDomain);
|
||||||
if (existing) { existing.push(routeName); }
|
if (existing) { existing.push(routeKey); }
|
||||||
else { domainToRoutes.set(knownDomain, [routeName]); }
|
else { domainToRoutes.set(knownDomain, [routeKey]); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const existing = domainToRoutes.get(pattern);
|
const existing = domainToRoutes.get(pattern);
|
||||||
if (existing) { existing.push(routeName); }
|
if (existing) { existing.push(routeKey); }
|
||||||
else { domainToRoutes.set(pattern, [routeName]); }
|
else { domainToRoutes.set(pattern, [routeKey]); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -777,10 +778,10 @@ export class MetricsManager {
|
|||||||
// For each route, compute the total request count across all its resolved domains
|
// For each route, compute the total request count across all its resolved domains
|
||||||
// so we can distribute throughput/connections proportionally
|
// so we can distribute throughput/connections proportionally
|
||||||
const routeTotalRequests = new Map<string, number>();
|
const routeTotalRequests = new Map<string, number>();
|
||||||
for (const [domain, routeNames] of domainToRoutes) {
|
for (const [domain, routeKeys] of domainToRoutes) {
|
||||||
const reqs = domainRequestTotals.get(domain) || 0;
|
const reqs = domainRequestTotals.get(domain) || 0;
|
||||||
for (const routeName of routeNames) {
|
for (const routeKey of routeKeys) {
|
||||||
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 0) + reqs);
|
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,16 +794,16 @@ export class MetricsManager {
|
|||||||
requestCount: number;
|
requestCount: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
for (const [domain, routeNames] of domainToRoutes) {
|
for (const [domain, routeKeys] of domainToRoutes) {
|
||||||
const domainReqs = domainRequestTotals.get(domain) || 0;
|
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||||
let totalConns = 0;
|
let totalConns = 0;
|
||||||
let totalIn = 0;
|
let totalIn = 0;
|
||||||
let totalOut = 0;
|
let totalOut = 0;
|
||||||
|
|
||||||
for (const routeName of routeNames) {
|
for (const routeKey of routeKeys) {
|
||||||
const conns = connectionsByRoute.get(routeName) || 0;
|
const conns = connectionsByRoute.get(routeKey) || 0;
|
||||||
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
|
||||||
const routeTotal = routeTotalRequests.get(routeName) || 0;
|
const routeTotal = routeTotalRequests.get(routeKey) || 0;
|
||||||
|
|
||||||
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
||||||
totalConns += conns * share;
|
totalConns += conns * share;
|
||||||
@@ -814,7 +815,7 @@ export class MetricsManager {
|
|||||||
activeConnections: Math.round(totalConns),
|
activeConnections: Math.round(totalConns),
|
||||||
bytesInPerSec: totalIn,
|
bytesInPerSec: totalIn,
|
||||||
bytesOutPerSec: totalOut,
|
bytesOutPerSec: totalOut,
|
||||||
routeCount: routeNames.length,
|
routeCount: routeKeys.length,
|
||||||
requestCount: domainReqs,
|
requestCount: domainReqs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,12 +198,11 @@ export class CertificateHandler {
|
|||||||
try {
|
try {
|
||||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||||
if (rustStatus) {
|
if (rustStatus) {
|
||||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
if (rustStatus.expiresAt > 0) {
|
||||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
expiryDate = new Date(rustStatus.expiresAt).toISOString();
|
||||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
|
||||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
|
||||||
status = rustStatus.status;
|
|
||||||
}
|
}
|
||||||
|
if (rustStatus.source) issuer = rustStatus.source;
|
||||||
|
status = rustStatus.isValid ? 'valid' : 'expired';
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Rust bridge may not support this command yet — ignore
|
// Rust bridge may not support this command yet — ignore
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queue = emailServer.deliveryQueue;
|
const queue = emailServer.deliveryQueue;
|
||||||
const item = queue.getItem(dataArg.emailId);
|
const item = emailServer.getQueueItem(dataArg.emailId);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return { success: false, error: 'Email not found in queue' };
|
return { success: false, error: 'Email not found in queue' };
|
||||||
@@ -82,22 +82,10 @@ export class EmailOpsHandler {
|
|||||||
*/
|
*/
|
||||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
if (!emailServer?.deliveryQueue) {
|
if (!emailServer) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
|
||||||
const queue = emailServer.deliveryQueue;
|
|
||||||
const queueMap = (queue as any).queue as Map<string, any>;
|
|
||||||
|
|
||||||
if (!queueMap) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const emails: interfaces.requests.IEmail[] = [];
|
|
||||||
|
|
||||||
for (const [id, item] of queueMap.entries()) {
|
|
||||||
emails.push(this.mapQueueItemToEmail(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by createdAt descending (newest first)
|
// Sort by createdAt descending (newest first)
|
||||||
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
@@ -110,12 +98,10 @@ export class EmailOpsHandler {
|
|||||||
*/
|
*/
|
||||||
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
if (!emailServer?.deliveryQueue) {
|
if (!emailServer) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const item = emailServer.getQueueItem(emailId);
|
||||||
const queue = emailServer.deliveryQueue;
|
|
||||||
const item = queue.getItem(emailId);
|
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -530,7 +530,8 @@ export class StatsHandler {
|
|||||||
nextRetry?: number;
|
nextRetry?: number;
|
||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
// TODO: Implement actual queue status collection
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
|
if (!emailServer) {
|
||||||
return {
|
return {
|
||||||
pending: 0,
|
pending: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
@@ -540,6 +541,41 @@ export class StatsHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queueStats = emailServer.getQueueStats();
|
||||||
|
const items = emailServer.getQueueItems()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
|
||||||
|
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
|
||||||
|
return right - left;
|
||||||
|
})
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((item) => {
|
||||||
|
const emailLike = item.processingResult;
|
||||||
|
const recipients = Array.isArray(emailLike?.to)
|
||||||
|
? emailLike.to
|
||||||
|
: Array.isArray(emailLike?.email?.to)
|
||||||
|
? emailLike.email.to
|
||||||
|
: [];
|
||||||
|
const subject = emailLike?.subject || emailLike?.email?.subject || '';
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
recipient: recipients[0] || '',
|
||||||
|
subject,
|
||||||
|
status: item.status,
|
||||||
|
attempts: item.attempts,
|
||||||
|
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending: queueStats.status.pending,
|
||||||
|
active: queueStats.status.processing,
|
||||||
|
failed: queueStats.status.failed,
|
||||||
|
retrying: queueStats.status.deferred,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async checkHealthStatus(): Promise<{
|
private async checkHealthStatus(): Promise<{
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
services: Array<{
|
services: Array<{
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.17.5',
|
version: '13.18.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:Plug',
|
icon: 'lucide:Plug',
|
||||||
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
||||||
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
|
description: `Total: ${this.formatNumber(this.statsState.serverStats?.totalConnections || 0)} connections`,
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'View Details',
|
name: 'View Details',
|
||||||
|
|||||||
Reference in New Issue
Block a user