Compare commits

...

18 Commits

Author SHA1 Message Date
8bbaf26813 v13.19.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-15 19:59:04 +00:00
39f449cbe4 feat(routes,email): persist system DNS routes with runtime hydration and add reusable email ops DNS helpers 2026-04-15 19:59:04 +00:00
e0386beb15 v13.18.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 13:11:48 +00:00
1d7e5495fa feat(email): add persistent smartmta storage and runtime-managed email domain syncing 2026-04-14 13:11:48 +00:00
9a378ae87f v13.17.9
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 09:33:41 +00:00
58fbc2b1e4 fix(monitoring): align domain activity metrics with id-keyed route data 2026-04-14 09:33:41 +00:00
20ea0ce683 v13.17.8
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 01:16:37 +00:00
bcea93753b fix(opsserver): align certificate status handling with the updated smartproxy response format 2026-04-14 01:16:37 +00:00
848515e424 v13.17.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:56:31 +00:00
38c9978969 fix(repo): no changes to commit 2026-04-14 00:56:31 +00:00
ee863b8178 v13.17.6
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:53:26 +00:00
9bb5a8bcc1 fix(dns,routes): keep DoH socket-handler routes runtime-only and prune stale persisted entries 2026-04-14 00:53:26 +00:00
5aa07e81c7 v13.17.5
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 23:02:42 +00:00
aec8b72ca3 fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior 2026-04-13 23:02:42 +00:00
466654ee4c v13.17.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:46:12 +00:00
f1a11e3f6a fix(ops-view-routes): sync route filter toggle selection via component changeSubject 2026-04-13 19:46:12 +00:00
e193b3a8eb v13.17.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:17:46 +00:00
1bbf31605c fix(monitoring): exclude unconfigured routes from domain activity aggregation 2026-04-13 19:17:46 +00:00
41 changed files with 2805 additions and 2865 deletions

View File

@@ -1,5 +1,67 @@
# Changelog
## 2026-04-15 - 13.19.0 - feat(routes,email)
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers
- Persist seeded DNS-over-HTTPS routes with stable system keys and hydrate socket handlers at runtime instead of treating them as runtime-only routes
- Restrict system-managed routes to toggle-only operations across the route manager, Ops API, and web UI while returning explicit mutation errors
- Add a shared email DNS record builder and cover email queue operations and handler behavior with new tests
## 2026-04-14 - 13.18.0 - feat(email)
add persistent smartmta storage and runtime-managed email domain syncing
- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence
- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart
- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation
## 2026-04-14 - 13.17.9 - fix(monitoring)
align domain activity metrics with id-keyed route data
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
- Add a regression test covering domain activity aggregation for routes identified only by id.
- Update the network activity UI to show formatted total connection counts in the active connections card.
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
## 2026-04-14 - 13.17.8 - fix(opsserver)
align certificate status handling with the updated smartproxy response format
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
- bump @push.rocks/smartproxy to ^27.7.3
- enable verbose output for the test script
## 2026-04-14 - 13.17.7 - fix(repo)
no changes to commit
## 2026-04-14 - 13.17.6 - fix(dns,routes)
keep DoH socket-handler routes runtime-only and prune stale persisted entries
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
normalize target profile route references and stabilize VPN host-IP client routing behavior
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
sync route filter toggle selection via component changeSubject
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
## 2026-04-13 - 13.17.2 - fix(monitoring)
exclude unconfigured routes from domain activity aggregation
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
## 2026-04-13 - 13.17.1 - fix(monitoring)
stop allocating route metrics to domains when no request data exists

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.17.1",
"version": "13.19.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -12,7 +12,7 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --logfile --timeout 60)",
"test": "(tstest test/ --verbose --logfile --timeout 60)",
"start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
@@ -50,11 +50,11 @@
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.6.0",
"@push.rocks/smartpath": "^6.0.0",
"@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/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
@@ -62,12 +62,12 @@
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.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/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.3.3",
"lru-cache": "^11.3.5",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
},

113
pnpm-lock.yaml generated
View File

@@ -69,11 +69,11 @@ importers:
specifier: 1.2.0
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
'@push.rocks/smartmta':
specifier: ^5.3.1
version: 5.3.1
specifier: ^5.3.3
version: 5.3.3
'@push.rocks/smartnetwork':
specifier: ^4.5.2
version: 4.5.2
specifier: ^4.6.0
version: 4.6.0
'@push.rocks/smartpath':
specifier: ^6.0.0
version: 6.0.0
@@ -81,8 +81,8 @@ importers:
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartproxy':
specifier: ^27.6.0
version: 27.6.0
specifier: ^27.7.4
version: 27.7.4
'@push.rocks/smartradius':
specifier: ^1.1.1
version: 1.1.1
@@ -105,8 +105,8 @@ importers:
specifier: ^8.0.2
version: 8.0.2
'@serve.zone/catalog':
specifier: ^2.12.3
version: 2.12.3(@tiptap/pm@2.27.2)
specifier: ^2.12.4
version: 2.12.4(@tiptap/pm@2.27.2)
'@serve.zone/interfaces':
specifier: ^5.3.0
version: 5.3.0
@@ -120,8 +120,8 @@ importers:
specifier: ^1.5.6
version: 1.5.6
lru-cache:
specifier: ^11.3.3
version: 11.3.3
specifier: ^11.3.5
version: 11.3.5
qrcode:
specifier: ^1.5.4
version: 1.5.4
@@ -147,6 +147,9 @@ importers:
'@types/node':
specifier: ^25.6.0
version: 25.6.0
typescript:
specifier: ^6.0.2
version: 6.0.2
packages:
@@ -365,9 +368,6 @@ packages:
'@design.estate/dees-element@2.2.4':
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':
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
@@ -1251,8 +1251,8 @@ packages:
'@push.rocks/smartmongo@5.1.1':
resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==}
'@push.rocks/smartmta@5.3.1':
resolution: {integrity: sha512-cEuXO56i/zL9eZS79eAesEW16ikdBJKLlEv9pLKkt2cmaHBWADGHjeOzJmsszQ9CSFcuhd41aHYVGMZXVvsG2g==}
'@push.rocks/smartmta@5.3.3':
resolution: {integrity: sha512-QxNob2yosDOhHMMjfUiQHfx8z+/UQQUdZY4ECATg3/xAMwnychR41IEVp6h7Qz3RjoJqS3NjRBThm9/jT02Gxg==}
engines: {node: '>=14.0.0'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
@@ -1260,8 +1260,8 @@ packages:
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
'@push.rocks/smartnetwork@4.5.2':
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
'@push.rocks/smartnetwork@4.6.0':
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
'@push.rocks/smartnftables@1.1.0':
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
@@ -1287,8 +1287,8 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@27.6.0':
resolution: {integrity: sha512-1mPzabUKhlC0EdeI7Hjee/aiptTsOLftbq8oWBTlIg9JhCQwkIs5UNGTJV/VvlEflJKnay8TbzLzlr95gUr/1w==}
'@push.rocks/smartproxy@27.7.4':
resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1591,8 +1591,8 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@serve.zone/catalog@2.12.3':
resolution: {integrity: sha512-/QLFjFcy/ig6cdr4517smSc/VCutW/qF/8lCM3v7tpQ5yLApjqiL314Dyvk9zzSwHpw69IeuM9EmPOeTuCY0iQ==}
'@serve.zone/catalog@2.12.4':
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
'@serve.zone/interfaces@5.3.0':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
@@ -3023,6 +3023,9 @@ packages:
libmime@5.3.7:
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
libmime@5.3.8:
resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==}
libqp@2.1.1:
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
@@ -3085,8 +3088,11 @@ packages:
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lru-cache@11.3.3:
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.3.5:
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
engines: {node: 20 || >=22}
lru-cache@7.18.3:
@@ -3096,8 +3102,8 @@ packages:
lucide@1.8.0:
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
mailparser@3.9.6:
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==}
mailparser@3.9.8:
resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
@@ -3434,8 +3440,8 @@ packages:
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
engines: {node: '>= 6.13.0'}
nodemailer@8.0.4:
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
nodemailer@8.0.5:
resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
engines: {node: '>=6.0.0'}
normalize-newline@4.1.0:
@@ -4937,18 +4943,6 @@ snapshots:
- supports-color
- 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':
dependencies:
'@design.estate/dees-domtools': 2.5.4
@@ -5169,7 +5163,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.0
'@push.rocks/smartlog': 3.2.2
'@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/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
@@ -5972,7 +5966,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 7.9.0
'@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/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
@@ -6420,7 +6414,7 @@ snapshots:
- supports-color
- vue
'@push.rocks/smartmta@5.3.1':
'@push.rocks/smartmta@5.3.3':
dependencies:
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartfs': 1.5.0
@@ -6429,8 +6423,8 @@ snapshots:
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2
'@tsclass/tsclass': 9.5.0
lru-cache: 11.3.3
mailparser: 3.9.6
lru-cache: 10.4.3
mailparser: 3.9.8
uuid: 13.0.0
transitivePeerDependencies:
- supports-color
@@ -6439,7 +6433,7 @@ snapshots:
dependencies:
handlebars: 4.7.9
'@push.rocks/smartnetwork@4.5.2':
'@push.rocks/smartnetwork@4.6.0':
dependencies:
'@push.rocks/smartdns': 7.9.0
'@push.rocks/smartrust': 1.3.2
@@ -6500,7 +6494,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfs': 1.5.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/smartpromise': 4.2.3
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
@@ -6521,7 +6515,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.6.0':
'@push.rocks/smartproxy@27.7.4':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -6923,12 +6917,12 @@ snapshots:
domhandler: 5.0.3
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:
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
'@design.estate/dees-wcctools': 3.9.0
transitivePeerDependencies:
- '@nuxt/kit'
- '@tiptap/pm'
@@ -8603,6 +8597,13 @@ snapshots:
libbase64: 1.3.0
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: {}
lightweight-charts@5.1.0:
@@ -8659,22 +8660,24 @@ snapshots:
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: {}
lucide@1.8.0: {}
mailparser@3.9.6:
mailparser@3.9.8:
dependencies:
'@zone-eu/mailsplit': 5.4.8
encoding-japanese: 2.2.0
he: 1.2.0
html-to-text: 9.0.5
iconv-lite: 0.7.2
libmime: 5.3.7
libmime: 5.3.8
linkify-it: 5.0.0
nodemailer: 8.0.4
nodemailer: 8.0.5
punycode.js: 2.3.1
tlds: 1.261.0
@@ -9179,7 +9182,7 @@ snapshots:
node-forge@1.4.0: {}
nodemailer@8.0.4: {}
nodemailer@8.0.5: {}
normalize-newline@4.1.0:
dependencies:
@@ -9304,7 +9307,7 @@ snapshots:
path-scurry@2.0.2:
dependencies:
lru-cache: 11.3.3
lru-cache: 11.3.5
minipass: 7.1.3
path-to-regexp@8.4.2: {}

1799
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
// Verify unified email server was initialized
expect(router.emailServer).toBeTruthy();
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
// Stop the router
await router.stop();

View File

@@ -0,0 +1,262 @@
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 persists DoH system routes and hydrates runtime socket handlers', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const persistedRoutes = await RouteDoc.findAll();
expect(persistedRoutes.length).toEqual(2);
expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
const mergedRoutes = routeManager.getMergedRoutes().routes;
expect(mergedRoutes.length).toEqual(2);
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
expect(appliedRoutes.length).toEqual(1);
for (const routeSet of appliedRoutes) {
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
expect(dnsQueryRoute).toBeDefined();
expect(resolveRoute).toBeDefined();
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
}
});
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const staleDnsQueryRoute = new RouteDoc();
staleDnsQueryRoute.id = 'stale-doh-query';
staleDnsQueryRoute.route = {
name: 'dns-over-https-dns-query',
match: {
ports: [443],
domains: ['ns1.example.com'],
path: '/dns-query',
},
action: {
type: 'socket-handler' as any,
} as any,
};
staleDnsQueryRoute.enabled = true;
staleDnsQueryRoute.createdAt = Date.now();
staleDnsQueryRoute.updatedAt = Date.now();
staleDnsQueryRoute.createdBy = 'test';
staleDnsQueryRoute.origin = 'dns';
await staleDnsQueryRoute.save();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const remainingRoutes = await RouteDoc.findAll();
expect(remainingRoutes.length).toEqual(2);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
expect(queryRoute?.id).toEqual('stale-doh-query');
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
});
tap.test('RouteConfigManager only allows toggling system routes', async () => {
await testDbPromise;
await clearTestState();
const smartProxy = {
updateRoutes: async (_routes: any[]) => {
return;
},
};
const routeManager = new RouteConfigManager(() => smartProxy as any);
await routeManager.initialize([
{
name: 'system-config-route',
match: {
ports: [443],
domains: ['app.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
tls: { mode: 'terminate' as const },
},
} as any,
], [], []);
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
expect(systemRoute).toBeDefined();
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
route: { name: 'renamed-system-route' } as any,
});
expect(updateResult.success).toEqual(false);
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
expect(deleteResult.success).toEqual(false);
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
expect(toggleResult.success).toEqual(true);
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
});
tap.test('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();

View File

@@ -0,0 +1,65 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { buildEmailDnsRecords } from '../ts/email/index.js';
tap.test('buildEmailDnsRecords uses the configured mail hostname for MX and includes DKIM when provided', async () => {
const records = buildEmailDnsRecords({
domain: 'example.com',
hostname: 'mail.example.com',
selector: 'selector1',
dkimValue: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
statuses: {
mx: 'valid',
spf: 'missing',
dkim: 'valid',
dmarc: 'unchecked',
},
});
expect(records).toEqual([
{
type: 'MX',
name: 'example.com',
value: '10 mail.example.com',
status: 'valid',
},
{
type: 'TXT',
name: 'example.com',
value: 'v=spf1 a mx ~all',
status: 'missing',
},
{
type: 'TXT',
name: 'selector1._domainkey.example.com',
value: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
status: 'valid',
},
{
type: 'TXT',
name: '_dmarc.example.com',
value: 'v=DMARC1; p=none; rua=mailto:dmarc@example.com',
status: 'unchecked',
},
]);
});
tap.test('buildEmailDnsRecords omits DKIM when no value is provided', async () => {
const records = buildEmailDnsRecords({
domain: 'example.net',
hostname: 'smtp.example.net',
mxPriority: 20,
});
expect(records.map((record) => record.name)).toEqual([
'example.net',
'example.net',
'_dmarc.example.net',
]);
expect(records[0].value).toEqual('20 smtp.example.net');
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,193 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { EmailDomainManager } from '../ts/email/index.js';
import { DcRouterDb, DomainDoc } from '../ts/db/index.js';
import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const emailDomain of await EmailDomainDoc.findAll()) {
await emailDomain.delete();
}
for (const domain of await DomainDoc.findAll()) {
await domain.delete();
}
};
const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => {
const doc = new DomainDoc();
doc.id = id;
doc.name = name;
doc.source = source;
doc.authoritative = source === 'dcrouter';
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
doc.createdBy = 'test';
await doc.save();
return doc;
};
const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({
ports: [2525],
hostname: 'mail.example.com',
domains: [
{
domain: 'static.example.com',
dnsMode: 'external-dns',
},
],
routes: [],
});
tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider');
const updateCalls: Array<{ domains?: any[] }> = [];
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
emailServer: {
updateOptions: (options: { domains?: any[] }) => {
updateCalls.push(options);
},
},
};
const manager = new EmailDomainManager(dcRouterStub);
await manager.start();
const created = await manager.createEmailDomain({
linkedDomainId: linkedDomain.id,
subdomain: 'mail',
dkimSelector: 'selector1',
rotateKeys: true,
rotationIntervalDays: 30,
});
const domainsAfterCreate = dcRouterStub.options.emailConfig.domains;
expect(domainsAfterCreate.length).toEqual(2);
expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true);
const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com');
expect(managedDomain).toBeTruthy();
expect(managedDomain?.dnsMode).toEqual('external-dns');
expect(managedDomain?.dkim?.selector).toEqual('selector1');
expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true);
await manager.updateEmailDomain(created.id, {
rotateKeys: false,
rateLimits: {
outbound: {
messagesPerMinute: 10,
},
},
});
const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains;
const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com');
expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false);
expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10);
await manager.deleteEmailDomain(created.id);
expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']);
});
tap.test('EmailDomainManager rejects domains already present in static config', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider');
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
};
const manager = new EmailDomainManager(dcRouterStub);
let error: Error | undefined;
try {
await manager.createEmailDomain({ linkedDomainId: linkedDomain.id });
} catch (err: unknown) {
error = err as Error;
}
expect(error?.message).toEqual('Email domain already configured for static.example.com');
});
tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter');
const stored = new EmailDomainDoc();
stored.id = 'managed-email-domain';
stored.domain = 'mail.managed.example.com';
stored.linkedDomainId = linkedDomain.id;
stored.subdomain = 'mail';
stored.dkim = {
selector: 'default',
keySize: 2048,
rotateKeys: false,
rotationIntervalDays: 90,
};
stored.dnsStatus = {
mx: 'unchecked',
spf: 'unchecked',
dkim: 'unchecked',
dmarc: 'unchecked',
};
stored.createdAt = new Date().toISOString();
stored.updatedAt = new Date().toISOString();
await stored.save();
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
};
const manager = new EmailDomainManager(dcRouterStub);
await manager.start();
const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com');
expect(managedDomain?.dnsMode).toEqual('internal-dns');
});
tap.test('cleanup', async () => {
const testDb = await testDbPromise;
await clearTestState();
await testDb.cleanup();
await tap.stopForcefully();
});
export default tap.start();

167
test/test.email-ops-api.ts Normal file
View File

@@ -0,0 +1,167 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { TypedRequest } from '@api.global/typedrequest';
import { DcRouter } from '../ts/index.js';
import * as interfaces from '../ts_interfaces/index.js';
const TEST_PORT = 3201;
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
let removedQueueItemId: string | undefined;
let lastEnqueueArgs: any[] | undefined;
const queueItems = [
{
id: 'failed-email-1',
status: 'failed',
attempts: 3,
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
lastError: '550 mailbox unavailable',
processingMode: 'mta',
route: undefined,
createdAt: new Date('2026-04-14T09:00:00.000Z'),
processingResult: {
from: 'sender@example.com',
to: ['recipient@example.net'],
cc: ['copy@example.net'],
subject: 'Older message',
text: 'hello',
headers: { 'x-test': '1' },
getMessageId: () => 'message-older',
getAttachmentsSize: () => 64,
},
},
{
id: 'delivered-email-1',
status: 'delivered',
attempts: 1,
processingMode: 'mta',
route: undefined,
createdAt: new Date('2026-04-14T11:00:00.000Z'),
processingResult: {
email: {
from: 'fresh@example.com',
to: ['new@example.net'],
cc: [],
subject: 'Newest message',
},
html: '<p>newest</p>',
text: 'newest',
headers: { 'x-fresh': 'true' },
getMessageId: () => 'message-newer',
getAttachmentsSize: () => 0,
},
},
];
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
testDcRouter = new DcRouter({
opsServerPort: TEST_PORT,
dbConfig: { enabled: false },
});
await testDcRouter.start();
testDcRouter.emailServer = {
getQueueItems: () => [...queueItems],
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
getQueueStats: () => ({
queueSize: 2,
status: {
pending: 0,
processing: 1,
failed: 1,
deferred: 1,
delivered: 1,
},
}),
deliveryQueue: {
enqueue: async (...args: any[]) => {
lastEnqueueArgs = args;
return 'resent-queue-id';
},
removeItem: async (id: string) => {
removedQueueItemId = id;
return true;
},
},
} as any;
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
});
tap.test('should login as admin for email API tests', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
BASE_URL,
'adminLoginWithUsernameAndPassword',
);
const response = await loginRequest.fire({
username: 'admin',
password: 'admin',
});
adminIdentity = response.identity;
expect(adminIdentity.jwt).toBeTruthy();
});
tap.test('should return queued emails through the email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetAllEmails>(BASE_URL, 'getAllEmails');
const response = await request.fire({
identity: adminIdentity,
});
expect(response.emails.map((email) => email.id)).toEqual(['delivered-email-1', 'failed-email-1']);
expect(response.emails[0].status).toEqual('delivered');
expect(response.emails[1].status).toEqual('bounced');
});
tap.test('should return email detail through the email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetEmailDetail>(BASE_URL, 'getEmailDetail');
const response = await request.fire({
identity: adminIdentity,
emailId: 'failed-email-1',
});
expect(response.email?.toList).toEqual(['recipient@example.net']);
expect(response.email?.cc).toEqual(['copy@example.net']);
expect(response.email?.rejectionReason).toEqual('550 mailbox unavailable');
expect(response.email?.headers).toEqual({ 'x-test': '1' });
});
tap.test('should expose queue status through the stats API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetQueueStatus>(BASE_URL, 'getQueueStatus');
const response = await request.fire({
identity: adminIdentity,
});
expect(response.queues.length).toEqual(1);
expect(response.queues[0].size).toEqual(0);
expect(response.queues[0].processing).toEqual(1);
expect(response.queues[0].failed).toEqual(1);
expect(response.queues[0].retrying).toEqual(1);
expect(response.totalItems).toEqual(3);
});
tap.test('should resend failed email through the admin email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ResendEmail>(BASE_URL, 'resendEmail');
const response = await request.fire({
identity: adminIdentity,
emailId: 'failed-email-1',
});
expect(response.success).toEqual(true);
expect(response.newQueueId).toEqual('resent-queue-id');
expect(removedQueueItemId).toEqual('failed-email-1');
expect(lastEnqueueArgs?.[0]).toEqual(queueItems[0].processingResult);
});
tap.test('should stop DCRouter after email API tests', async () => {
await testDcRouter.stop();
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,107 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EmailOpsHandler } from '../ts/opsserver/handlers/email-ops.handler.js';
import { StatsHandler } from '../ts/opsserver/handlers/stats.handler.js';
const createRouterStub = () => ({
addTypedHandler: (_handler: unknown) => {},
});
const queueItems = [
{
id: 'older-failed',
status: 'failed',
attempts: 3,
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
lastError: '550 mailbox unavailable',
createdAt: new Date('2026-04-14T09:00:00.000Z'),
processingResult: {
from: 'sender@example.com',
to: ['recipient@example.net'],
cc: ['copy@example.net'],
subject: 'Older message',
text: 'hello',
headers: { 'x-test': '1' },
getMessageId: () => 'message-older',
getAttachmentsSize: () => 64,
},
},
{
id: 'newer-delivered',
status: 'delivered',
attempts: 1,
createdAt: new Date('2026-04-14T11:00:00.000Z'),
processingResult: {
email: {
from: 'fresh@example.com',
to: ['new@example.net'],
cc: [],
subject: 'Newest message',
},
html: '<p>newest</p>',
text: 'newest',
headers: { 'x-fresh': 'true' },
getMessageId: () => 'message-newer',
getAttachmentsSize: () => 0,
},
},
];
tap.test('EmailOpsHandler maps queue items using public email server APIs', async () => {
const opsHandler = new EmailOpsHandler({
viewRouter: createRouterStub(),
adminRouter: createRouterStub(),
dcRouterRef: {
emailServer: {
getQueueItems: () => queueItems,
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
},
},
} as any);
const emails = (opsHandler as any).getAllQueueEmails();
expect(emails.map((email: any) => email.id)).toEqual(['newer-delivered', 'older-failed']);
expect(emails[0].status).toEqual('delivered');
expect(emails[1].status).toEqual('bounced');
expect(emails[0].messageId).toEqual('message-newer');
const detail = (opsHandler as any).getEmailDetail('older-failed');
expect(detail?.toList).toEqual(['recipient@example.net']);
expect(detail?.cc).toEqual(['copy@example.net']);
expect(detail?.rejectionReason).toEqual('550 mailbox unavailable');
expect(detail?.headers).toEqual({ 'x-test': '1' });
});
tap.test('StatsHandler reports queue status using public email server APIs', async () => {
const statsHandler = new StatsHandler({
viewRouter: createRouterStub(),
dcRouterRef: {
emailServer: {
getQueueStats: () => ({
queueSize: 2,
status: {
pending: 0,
processing: 1,
failed: 1,
deferred: 1,
delivered: 1,
},
}),
getQueueItems: () => queueItems,
},
},
} as any);
const queueStatus = await (statsHandler as any).getQueueStatus();
expect(queueStatus.pending).toEqual(0);
expect(queueStatus.active).toEqual(1);
expect(queueStatus.failed).toEqual(1);
expect(queueStatus.retrying).toEqual(1);
expect(queueStatus.items.map((item: any) => item.id)).toEqual(['newer-delivered', 'older-failed']);
expect(queueStatus.items[1].nextRetry).toEqual(new Date('2026-04-14T10:00:00.000Z').getTime());
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -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();

View File

@@ -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();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.17.1',
version: '13.19.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -9,6 +9,7 @@ import {
type IUnifiedEmailServerOptions,
type IEmailRoute,
type IEmailDomainConfig,
type IStorageManagerLike,
} from '@push.rocks/smartmta';
import { logger } from './logger.js';
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
@@ -29,7 +30,8 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js';
import { AcmeConfigManager } from './acme/manager.acme-config.js';
import { EmailDomainManager } from './email/classes.email-domain.manager.js';
import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
import type { IRoute } from '../ts_interfaces/data/route-management.js';
export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -248,15 +250,13 @@ export class DcRouter {
public radiusServer?: RadiusServer;
public opsServer!: OpsServer;
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: 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}`);
},
};
public storageManager: IStorageManagerLike;
// Unified database (smartdata + LocalSmartDb or external MongoDB)
public dcRouterDb?: DcRouterDb;
@@ -316,6 +316,8 @@ export class DcRouter {
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -328,6 +330,10 @@ export class DcRouter {
// Resolve all data paths from 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
this.serviceManager = new plugins.taskbuffer.ServiceManager({
@@ -451,9 +457,13 @@ export class DcRouter {
.dependsOn('DcRouterDb')
.withStart(async () => {
this.emailDomainManager = new EmailDomainManager(this);
await this.emailDomainManager.start();
})
.withStop(async () => {
this.emailDomainManager = undefined;
if (this.emailDomainManager) {
await this.emailDomainManager.stop();
this.emailDomainManager = undefined;
}
}),
);
}
@@ -547,7 +557,9 @@ export class DcRouter {
await this.referenceResolver.initialize();
// Initialize target profile manager
this.targetProfileManager = new TargetProfileManager();
this.targetProfileManager = new TargetProfileManager(
() => this.routeConfigManager?.getRoutes() || new Map(),
);
await this.targetProfileManager.initialize();
this.routeConfigManager = new RouteConfigManager(
@@ -560,7 +572,10 @@ export class DcRouter {
return [];
}
return this.targetProfileManager.getMatchingClientIps(
route, routeId, this.vpnManager.listClients(),
route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
);
}
: undefined,
@@ -575,6 +590,8 @@ export class DcRouter {
this.tunnelManager.syncAllowedEdges();
}
},
undefined,
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
);
this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize();
@@ -583,6 +600,7 @@ export class DcRouter {
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
await this.targetProfileManager.normalizeAllRouteRefs();
// Seed default profiles/targets if DB is empty and seeding is enabled
const seeder = new DbSeeder(this.referenceResolver);
@@ -603,19 +621,20 @@ export class DcRouter {
// Email Server: optional, depends on SmartProxy
if (this.options.emailConfig) {
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
if (this.options.dbConfig?.enabled !== false) {
emailServiceDeps.push('EmailDomainManager');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('EmailServer')
.optional()
.dependsOn('SmartProxy')
.dependsOn(...emailServiceDeps)
.withStart(async () => {
await this.setupUnifiedEmailHandling();
})
.withStop(async () => {
if (this.emailServer) {
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
this.clearEmailEventSubscriptions();
await this.emailServer.stop();
this.emailServer = undefined;
}
@@ -629,7 +648,7 @@ export class DcRouter {
this.serviceManager.addService(
new plugins.taskbuffer.Service('DnsServer')
.optional()
.dependsOn('SmartProxy')
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
.withStart(async () => {
await this.setupDnsWithSocketHandler();
})
@@ -886,7 +905,7 @@ export class DcRouter {
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.
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
@@ -898,8 +917,10 @@ export class DcRouter {
}
this.seedDnsRoutes = [];
this.runtimeDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
this.seedDnsRoutes = this.generateDnsRoutes();
this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false });
this.runtimeDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: true });
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
}
@@ -907,7 +928,7 @@ export class DcRouter {
let routes: plugins.smartproxy.IRouteConfig[] = [
...this.seedConfigRoutes,
...this.seedEmailRoutes,
...this.seedDnsRoutes,
...this.runtimeDnsRoutes,
];
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
@@ -1323,19 +1344,20 @@ export class DcRouter {
/**
* Generate SmartProxy routes for DNS configuration
*/
private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] {
private generateDnsRoutes(options?: { includeSocketHandler?: boolean }): plugins.smartproxy.IRouteConfig[] {
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
return [];
}
const includeSocketHandler = options?.includeSocketHandler !== false;
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Create routes for DNS-over-HTTPS paths
const dohPaths = ['/dns-query', '/resolve'];
// Use the first nameserver domain for DoH routes
const primaryNameserver = this.options.dnsNsDomains[0];
for (const path of dohPaths) {
const dohRoute: plugins.smartproxy.IRouteConfig = {
name: `dns-over-https-${path.replace('/', '')}`,
@@ -1344,18 +1366,42 @@ export class DcRouter {
domains: [primaryNameserver],
path: path
},
action: {
type: 'socket-handler' as any,
socketHandler: this.createDnsSocketHandler()
} as any
action: includeSocketHandler
? {
type: 'socket-handler' as any,
socketHandler: this.createDnsSocketHandler()
} as any
: {
type: 'socket-handler' as any,
} as any
};
dnsRoutes.push(dohRoute);
}
return dnsRoutes;
}
private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
const routeName = storedRoute.route.name || '';
const isDohRoute = storedRoute.origin === 'dns'
&& storedRoute.route.action?.type === 'socket-handler'
&& routeName.startsWith('dns-over-https-');
if (!isDohRoute) {
return undefined;
}
return {
...storedRoute.route,
action: {
...storedRoute.route.action,
type: 'socket-handler' as any,
socketHandler: this.createDnsSocketHandler(),
} as any,
};
}
/**
* Check if a domain matches a pattern (including wildcard support)
* @param domain The domain to check
@@ -1457,7 +1503,6 @@ export class DcRouter {
await this.routeConfigManager.initialize(
this.seedConfigRoutes 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[],
);
}
@@ -1505,40 +1550,74 @@ export class DcRouter {
...this.options.emailConfig,
domains: transformedDomains,
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
this.emailServer = new UnifiedEmailServer(this, emailConfig);
this.clearEmailEventSubscriptions();
// Set up error handling
this.emailServer.on('error', (err: Error) => {
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
});
// Start the server
await this.emailServer.start();
// Wire delivery events to MetricsManager and logger
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' });
});
}
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
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();
logger.log('warn', 'Email bounce processed', { zone: 'email' });
});
updateQueueSize();
}
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
@@ -1568,11 +1647,7 @@ export class DcRouter {
try {
// Stop the unified email server which contains all components
if (this.emailServer) {
// Remove listeners before stopping to prevent leaks on config update cycles
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
this.clearEmailEventSubscriptions();
await this.emailServer.stop();
logger.log('info', 'Unified email server stopped');
this.emailServer = undefined;
@@ -1777,14 +1852,14 @@ export class DcRouter {
// Generate and register authoritative records
const authoritativeRecords = await this.generateAuthoritativeRecords();
// Generate email DNS records
const emailDnsRecords = await this.generateEmailDnsRecords();
// Initialize DKIM for all email domains
await this.initializeDkimForEmailDomains();
// Load DKIM records from JSON files (they should now exist)
const dkimRecords = await this.loadDkimRecords();
// Generate email DNS records
const emailDnsRecords = await this.generateEmailDnsRecords();
// Ensure DKIM keys exist for internal-dns domains before generating records.
await this.initializeDkimForEmailDomains();
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
const dkimRecords = await this.loadDkimRecords();
// Combine all records: authoritative, email, DKIM, and user-defined
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
@@ -1895,37 +1970,20 @@ export class DcRouter {
for (const domainConfig of internalDnsDomains) {
const domain = domainConfig.domain;
const ttl = domainConfig.dns?.internal?.ttl || 3600;
const mxPriority = domainConfig.dns?.internal?.mxPriority || 10;
// MX record - points to the domain itself for email handling
records.push({
name: domain,
type: 'MX',
value: `${mxPriority} ${domain}`,
ttl
});
// SPF record - using sensible defaults
const spfRecord = 'v=spf1 a mx ~all';
records.push({
name: domain,
type: 'TXT',
value: spfRecord,
ttl
});
// DMARC record - using sensible defaults
const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring
const dmarcEmail = `dmarc@${domain}`;
records.push({
name: `_dmarc.${domain}`,
type: 'TXT',
value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`,
ttl
});
// Note: DKIM records will be generated later when DKIM keys are available
// They require the DKIMCreator which is part of the email server
const requiredRecords = buildEmailDnsRecords({
domain,
hostname: this.options.emailConfig.hostname,
mxPriority: domainConfig.dns?.internal?.mxPriority,
}).filter((record) => !record.name.includes('._domainkey.'));
for (const record of requiredRecords) {
records.push({
name: record.name,
type: record.type,
value: record.value,
ttl,
});
}
}
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
@@ -1933,54 +1991,30 @@ export class DcRouter {
}
/**
* Load DKIM records from JSON files
* Reads all *.dkimrecord.json files from the DNS records directory
* Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
*/
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
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;
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
return records;
}
for (const domainConfig of this.options.emailConfig.domains) {
if (domainConfig.dnsMode !== 'internal-dns') {
continue;
}
// Read all files in the directory
const files = plugins.fs.readdirSync(dnsDir);
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
// Load each DKIM record
for (const file of dkimFiles) {
try {
const filePath = plugins.path.join(dnsDir, file);
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({
name: dkimRecord.name,
type: 'TXT',
value: dkimRecord.value,
ttl: 3600 // Standard DKIM TTL
});
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
} else {
logger.log('warn', `Invalid DKIM record structure in ${file}`);
}
} catch (error: unknown) {
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
}
const selector = domainConfig.dkim?.selector || 'default';
try {
const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
records.push({
name: dkimRecord.name,
type: 'TXT',
value: dkimRecord.value,
ttl: domainConfig.dns?.internal?.ttl || 3600,
});
} catch (error: unknown) {
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;
@@ -2007,12 +2041,17 @@ export class DcRouter {
// Ensure necessary directories exist
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) {
if (domainConfig.dnsMode !== 'internal-dns') {
continue;
}
try {
// Generate DKIM keys for all domains, regardless of DNS mode
// This ensures keys are ready even if DNS mode changes later
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
await dkimCreator.handleDKIMKeysForSelector(
domainConfig.domain,
domainConfig.dkim?.selector || 'default',
domainConfig.dkim?.keySize || 2048,
);
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
} catch (error: unknown) {
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
@@ -2142,6 +2181,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
@@ -2179,7 +2237,7 @@ export class DcRouter {
// Pass current bootstrap routes so the manager can derive edge ports initially.
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
// 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[]);
// If ConfigManagers finished before us, re-apply routes
@@ -2283,8 +2341,11 @@ export class DcRouter {
// Resolve DNS A records for matched domains (with caching)
for (const domain of domains) {
const stripped = domain.replace(/^\*\./, '');
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
if (this.isWildcardVpnDomain(domain)) {
this.logSkippedWildcardAllowedIp(domain);
continue;
}
const resolvedIps = await this.resolveVpnDomainIPs(domain);
for (const ip of resolvedIps) {
ips.add(`${ip}/32`);
}
@@ -2303,6 +2364,8 @@ export class DcRouter {
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
private warnedWildcardVpnDomains = new Set<string>();
/**
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
@@ -2328,6 +2391,19 @@ export class DcRouter {
}
}
private isWildcardVpnDomain(domain: string): boolean {
return domain.includes('*');
}
private logSkippedWildcardAllowedIp(domain: string): void {
if (this.warnedWildcardVpnDomains.has(domain)) return;
this.warnedWildcardVpnDomains.add(domain);
logger.log(
'warn',
`VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
);
}
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
// via the getVpnAllowList callback — no longer a separate method here.

View File

@@ -14,6 +14,11 @@ import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] };
export interface IRouteMutationResult {
success: boolean;
message?: string;
}
/**
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
@@ -55,6 +60,8 @@ export class RouteConfigManager {
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
) {}
/** Expose routes map for reference resolution lookups. */
@@ -62,8 +69,13 @@ export class RouteConfigManager {
return this.routes;
}
public getRoute(id: string): IRoute | undefined {
return this.routes.get(id);
}
/**
* 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(
configRoutes: IDcRouterRouteConfig[] = [],
@@ -92,6 +104,7 @@ export class RouteConfigManager {
id: route.id,
enabled: route.enabled,
origin: route.origin,
systemKey: route.systemKey,
createdAt: route.createdAt,
updatedAt: route.updatedAt,
metadata: route.metadata,
@@ -151,9 +164,21 @@ export class RouteConfigManager {
enabled?: boolean;
metadata?: Partial<IRouteMetadata>;
},
): Promise<boolean> {
): Promise<IRouteMutationResult> {
const stored = this.routes.get(id);
if (!stored) return false;
if (!stored) {
return { success: false, message: 'Route not found' };
}
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
&& patch.metadata === undefined;
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
return {
success: false,
message: 'System routes are managed by the system and can only be toggled',
};
}
if (patch.route) {
const mergedAction = patch.route.action
@@ -187,19 +212,29 @@ export class RouteConfigManager {
await this.persistRoute(stored);
await this.applyRoutes();
return true;
return { success: true };
}
public async deleteRoute(id: string): Promise<boolean> {
if (!this.routes.has(id)) return false;
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
const stored = this.routes.get(id);
if (!stored) {
return { success: false, message: 'Route not found' };
}
if (stored.origin !== 'api') {
return {
success: false,
message: 'System routes are managed by the system and cannot be deleted',
};
}
this.routes.delete(id);
const doc = await RouteDoc.findById(id);
if (doc) await doc.delete();
await this.applyRoutes();
return true;
return { success: true };
}
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
return this.updateRoute(id, { enabled });
}
@@ -215,29 +250,28 @@ export class RouteConfigManager {
seedRoutes: IDcRouterRouteConfig[],
origin: 'config' | 'email' | 'dns',
): Promise<void> {
if (seedRoutes.length === 0) return;
const seedSystemKeys = new Set<string>();
const seedNames = new Set<string>();
let seeded = 0;
let updated = 0;
for (const route of seedRoutes) {
const name = route.name || '';
seedNames.add(name);
// Check if a route with this name+origin already exists in memory
let existingId: string | undefined;
for (const [id, r] of this.routes) {
if (r.origin === origin && r.route.name === name) {
existingId = id;
break;
}
if (name) {
seedNames.add(name);
}
const systemKey = this.buildSystemRouteKey(origin, route);
if (systemKey) {
seedSystemKeys.add(systemKey);
}
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
if (existingId) {
// Update route config but preserve enabled state
const existing = this.routes.get(existingId)!;
existing.route = route;
existing.systemKey = systemKey;
existing.updatedAt = Date.now();
await this.persistRoute(existing);
updated++;
@@ -253,6 +287,7 @@ export class RouteConfigManager {
updatedAt: now,
createdBy: 'system',
origin,
systemKey,
};
this.routes.set(id, newRoute);
await this.persistRoute(newRoute);
@@ -263,7 +298,12 @@ export class RouteConfigManager {
// Delete stale routes: same origin but name not in current seed set
const staleIds: string[] = [];
for (const [id, r] of this.routes) {
if (r.origin === origin && !seedNames.has(r.route.name || '')) {
if (r.origin !== origin) continue;
const routeName = r.route.name || '';
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
if (!matchesSeedSystemKey && !matchesSeedName) {
staleIds.push(id);
}
}
@@ -282,21 +322,56 @@ export class RouteConfigManager {
// Private: persistence
// =========================================================================
private buildSystemRouteKey(
origin: 'config' | 'email' | 'dns',
route: IDcRouterRouteConfig,
): string | undefined {
const name = route.name?.trim();
if (!name) return undefined;
return `${origin}:${name}`;
}
private findExistingSeedRouteId(
origin: 'config' | 'email' | 'dns',
route: IDcRouterRouteConfig,
systemKey?: string,
): string | undefined {
const routeName = route.name || '';
for (const [id, storedRoute] of this.routes) {
if (storedRoute.origin !== origin) continue;
if (systemKey && storedRoute.systemKey === systemKey) {
return id;
}
if (storedRoute.route.name === routeName) {
return id;
}
}
return undefined;
}
private async loadRoutes(): Promise<void> {
const docs = await RouteDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.routes.set(doc.id, {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
origin: doc.origin || 'api',
metadata: doc.metadata,
});
}
if (!doc.id) continue;
const storedRoute: IRoute = {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
origin: doc.origin || 'api',
systemKey: doc.systemKey,
metadata: doc.metadata,
};
this.routes.set(doc.id, storedRoute);
}
if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
@@ -311,6 +386,7 @@ export class RouteConfigManager {
existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin;
existingDoc.systemKey = stored.systemKey;
existingDoc.metadata = stored.metadata;
await existingDoc.save();
} else {
@@ -322,6 +398,7 @@ export class RouteConfigManager {
doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy;
doc.origin = stored.origin;
doc.systemKey = stored.systemKey;
doc.metadata = stored.metadata;
await doc.save();
}
@@ -389,36 +466,18 @@ export class RouteConfigManager {
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
for (const route of this.routes.values()) {
if (route.enabled) {
let r = route.route;
if (http3Config?.enabled !== false) {
r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config });
}
enabledRoutes.push(injectVpn(r, route.id));
enabledRoutes.push(this.prepareStoredRouteForApply(route));
}
}
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
for (const route of runtimeRoutes) {
enabledRoutes.push(this.prepareRouteForApply(route));
}
await smartProxy.updateRoutes(enabledRoutes);
// Notify listeners (e.g. RemoteIngressManager) of the route set
@@ -429,4 +488,44 @@ export class RouteConfigManager {
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
});
}
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
}
private prepareRouteForApply(
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
let preparedRoute = route;
const http3Config = this.getHttp3Config?.();
if (http3Config?.enabled !== false) {
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
}
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],
},
};
}
}

View File

@@ -13,6 +13,10 @@ import type { IRoute } from '../../ts_interfaces/data/route-management.js';
export class TargetProfileManager {
private profiles = new Map<string, ITargetProfile>();
constructor(
private getAllRoutes?: () => Map<string, IRoute>,
) {}
// =========================================================================
// Lifecycle
// =========================================================================
@@ -43,13 +47,14 @@ export class TargetProfileManager {
const id = plugins.uuid.v4();
const now = Date.now();
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
const profile: ITargetProfile = {
id,
name: data.name,
description: data.description,
domains: data.domains,
targets: data.targets,
routeRefs: data.routeRefs,
routeRefs,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
@@ -70,11 +75,19 @@ export class TargetProfileManager {
throw new Error(`Target profile '${id}' not found`);
}
if (patch.name !== undefined && patch.name !== profile.name) {
for (const existing of this.profiles.values()) {
if (existing.id !== id && existing.name === patch.name) {
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
}
}
}
if (patch.name !== undefined) profile.name = patch.name;
if (patch.description !== undefined) profile.description = patch.description;
if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
profile.updatedAt = Date.now();
await this.persistProfile(profile);
@@ -127,6 +140,29 @@ export class TargetProfileManager {
return this.profiles.get(id);
}
/**
* Normalize stored route references to route IDs when they can be resolved
* uniquely against the current route registry.
*/
public async normalizeAllRouteRefs(): Promise<void> {
const allRoutes = this.getAllRoutes?.();
if (!allRoutes?.size) return;
for (const profile of this.profiles.values()) {
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
profile.routeRefs,
allRoutes,
'bestEffort',
);
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
profile.routeRefs = normalizedRouteRefs;
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
}
}
public listProfiles(): ITargetProfile[] {
return [...this.profiles.values()];
}
@@ -178,9 +214,11 @@ export class TargetProfileManager {
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(),
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || [];
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue;
@@ -194,7 +232,13 @@ export class TargetProfileManager {
const profile = this.profiles.get(profileId);
if (!profile) continue;
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
const matchResult = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
if (matchResult === 'full') {
fullAccess = true;
break; // No need to check more profiles
@@ -224,6 +268,7 @@ export class TargetProfileManager {
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
// Collect all access specifiers from assigned profiles
for (const profileId of targetProfileIds) {
@@ -247,7 +292,12 @@ export class TargetProfileManager {
// Route references: scan all routes
for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue;
if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
if (this.routeMatchesProfile(
route.route as IDcRouterRouteConfig,
routeId,
profile,
routeNameIndex,
)) {
const routeDomains = (route.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
@@ -275,9 +325,16 @@ export class TargetProfileManager {
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
routeNameIndex: Map<string, string[]>,
): boolean {
const routeDomains: string[] = (route.match as any)?.domains || [];
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
const result = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
return result !== 'none';
}
@@ -294,11 +351,17 @@ export class TargetProfileManager {
routeId: string | undefined,
profile: ITargetProfile,
routeDomains: string[],
routeNameIndex: Map<string, string[]>,
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
// 1. Route reference match → full access
if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
const matchingRouteIds = routeNameIndex.get(route.name) || [];
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
return 'full';
}
}
}
// 2. Domain match
@@ -362,6 +425,66 @@ export class TargetProfileManager {
return false;
}
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
}
private normalizeRouteRefsAgainstRoutes(
routeRefs: string[] | undefined,
allRoutes: Map<string, IRoute>,
mode: 'strict' | 'bestEffort',
): string[] | undefined {
if (!routeRefs?.length) return undefined;
if (!allRoutes.size) return [...new Set(routeRefs)];
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
const normalizedRefs = new Set<string>();
for (const routeRef of routeRefs) {
if (allRoutes.has(routeRef)) {
normalizedRefs.add(routeRef);
continue;
}
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
if (matchingRouteIds.length === 1) {
normalizedRefs.add(matchingRouteIds[0]);
continue;
}
if (mode === 'bestEffort') {
normalizedRefs.add(routeRef);
continue;
}
if (matchingRouteIds.length > 1) {
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
}
throw new Error(`Route reference '${routeRef}' not found`);
}
return [...normalizedRefs];
}
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
const routeNameIndex = new Map<string, string[]>();
for (const [routeId, route] of allRoutes) {
const routeName = route.route.name;
if (!routeName) continue;
const matchingRouteIds = routeNameIndex.get(routeName) || [];
matchingRouteIds.push(routeId);
routeNameIndex.set(routeName, matchingRouteIds);
}
return routeNameIndex;
}
private sameStringArray(left?: string[], right?: string[]): boolean {
if (!left?.length && !right?.length) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
// =========================================================================
// Private: persistence
// =========================================================================

View File

@@ -29,6 +29,9 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
@plugins.smartdata.svDb()
public origin!: 'config' | 'email' | 'dns' | 'api';
@plugins.smartdata.svDb()
public systemKey?: string;
@plugins.smartdata.svDb()
public metadata?: IRouteMetadata;
@@ -51,4 +54,8 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
return await RouteDoc.getInstances({ origin });
}
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ systemKey });
}
}

View File

@@ -97,8 +97,8 @@ export class DnsManager {
if (hasLegacyConfig) {
logger.log(
'warn',
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
'Manage DNS via the Domains UI instead.',
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
);
}
return;

View File

@@ -1,10 +1,12 @@
import * as plugins from '../plugins.js';
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
import { logger } from '../logger.js';
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
import type { DnsManager } from '../dns/manager.dns.js';
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
import { buildEmailDnsRecords } from './email-dns-records.js';
/**
* EmailDomainManager — orchestrates email domain setup.
@@ -15,9 +17,12 @@ import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_i
*/
export class EmailDomainManager {
private dcRouter: any; // DcRouter — avoids circular import
private readonly baseEmailDomains: IEmailDomainConfig[];
constructor(dcRouterRef: any) {
this.dcRouter = dcRouterRef;
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
private get dnsManager(): DnsManager | undefined {
@@ -32,6 +37,12 @@ export class EmailDomainManager {
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
// ---------------------------------------------------------------------------
@@ -64,6 +75,9 @@ export class EmailDomainManager {
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
// Check for duplicates
if (this.isDomainAlreadyConfigured(domainName)) {
throw new Error(`Email domain already configured for ${domainName}`);
}
const existing = await EmailDomainDoc.findByDomain(domainName);
if (existing) {
throw new Error(`Email domain already exists for ${domainName}`);
@@ -77,8 +91,8 @@ export class EmailDomainManager {
let publicKey: string | undefined;
if (this.dkimCreator) {
try {
await this.dkimCreator.handleDKIMKeysForDomain(domainName);
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
// Extract public key from the DNS record value
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
publicKey = match ? match[1] : undefined;
@@ -110,6 +124,7 @@ export class EmailDomainManager {
doc.createdAt = now;
doc.updatedAt = now;
await doc.save();
await this.syncManagedDomainsToRuntime();
logger.log('info', `Email domain created: ${domainName}`);
return this.docToInterface(doc);
@@ -131,12 +146,14 @@ export class EmailDomainManager {
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
doc.updatedAt = new Date().toISOString();
await doc.save();
await this.syncManagedDomainsToRuntime();
}
public async deleteEmailDomain(id: string): Promise<void> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
await doc.delete();
await this.syncManagedDomainsToRuntime();
logger.log('info', `Email domain deleted: ${doc.domain}`);
}
@@ -153,37 +170,25 @@ export class EmailDomainManager {
const domain = doc.domain;
const selector = doc.dkim.selector;
const publicKey = doc.dkim.publicKey || '';
const hostname = this.emailHostname;
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
const records: IEmailDnsRecord[] = [
{
type: 'MX',
name: domain,
value: `10 ${hostname}`,
status: doc.dnsStatus.mx,
},
{
type: 'TXT',
name: domain,
value: 'v=spf1 a mx ~all',
status: doc.dnsStatus.spf,
},
{
type: 'TXT',
name: `${selector}._domainkey.${domain}`,
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
status: doc.dnsStatus.dkim,
},
{
type: 'TXT',
name: `_dmarc.${domain}`,
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
status: doc.dnsStatus.dmarc,
},
];
if (this.dkimCreator) {
try {
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
dkimValue = dnsRecord.value;
} catch (err: unknown) {
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
}
}
return records;
return buildEmailDnsRecords({
domain,
hostname,
selector,
dkimValue,
statuses: doc.dnsStatus,
});
}
// ---------------------------------------------------------------------------
@@ -207,17 +212,7 @@ export class EmailDomainManager {
for (const required of requiredRecords) {
// Check if a matching record already exists
const exists = existingRecords.some((r) => {
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;
});
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
if (!exists) {
try {
@@ -259,16 +254,23 @@ export class EmailDomainManager {
const resolver = new plugins.dns.promises.Resolver();
// 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
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
// 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
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.updatedAt = new Date().toISOString();
@@ -277,10 +279,28 @@ export class EmailDomainManager {
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 {
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 {
return 'missing';
}
@@ -289,13 +309,19 @@ export class EmailDomainManager {
private async checkTxtRecord(
resolver: plugins.dns.promises.Resolver,
name: string,
prefix: string,
expectedValue?: string,
): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveTxt(name);
const flat = records.map((r) => r.join(''));
const found = flat.some((r) => r.startsWith(prefix));
return found ? 'valid' : 'missing';
if (flat.length === 0) {
return 'missing';
}
if (!expectedValue) {
return 'valid';
}
const found = flat.some((record) => record.trim() === expectedValue.trim());
return found ? 'valid' : 'invalid';
} catch {
return 'missing';
}
@@ -318,4 +344,63 @@ export class EmailDomainManager {
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 });
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,53 @@
import type {
IEmailDnsRecord,
TDnsRecordStatus,
} from '../../ts_interfaces/data/email-domain.js';
type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
export interface IBuildEmailDnsRecordsOptions {
domain: string;
hostname: string;
selector?: string;
dkimValue?: string;
mxPriority?: number;
dmarcPolicy?: string;
dmarcRua?: string;
statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
}
export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
const selector = options.selector || 'default';
const records: IEmailDnsRecord[] = [
{
type: 'MX',
name: options.domain,
value: `${options.mxPriority ?? 10} ${options.hostname}`,
status: statusFor('mx'),
},
{
type: 'TXT',
name: options.domain,
value: 'v=spf1 a mx ~all',
status: statusFor('spf'),
},
{
type: 'TXT',
name: `_dmarc.${options.domain}`,
value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
status: statusFor('dmarc'),
},
];
if (options.dkimValue) {
records.splice(2, 0, {
type: 'TXT',
name: `${selector}._domainkey.${options.domain}`,
value: options.dkimValue,
status: statusFor('dkim'),
});
}
return records;
}

View File

@@ -1 +1,3 @@
export * from './classes.email-domain.manager.js';
export * from './classes.smartmta-storage-manager.js';
export * from './email-dns-records.js';

View File

@@ -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[]>();
if (this.dcRouter.smartProxy) {
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)
? route.match.domains
: [route.match.domains];
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);
}
// Build reverse map: concrete domain → route name(s)
// Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>();
for (const [routeName, domains] of routeDomains) {
for (const [routeKey, domains] of routeDomains) {
for (const pattern of domains) {
if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const knownDomain of allKnownDomains) {
if (regex.test(knownDomain)) {
const existing = domainToRoutes.get(knownDomain);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(knownDomain, [routeName]); }
if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(knownDomain, [routeKey]); }
}
}
} else {
const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(pattern, [routeName]); }
if (existing) { existing.push(routeKey); }
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
// so we can distribute throughput/connections proportionally
const routeTotalRequests = new Map<string, number>();
for (const [domain, routeNames] of domainToRoutes) {
for (const [domain, routeKeys] of domainToRoutes) {
const reqs = domainRequestTotals.get(domain) || 0;
for (const routeName of routeNames) {
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 0) + reqs);
for (const routeKey of routeKeys) {
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
}
}
@@ -792,19 +793,17 @@ export class MetricsManager {
routeCount: number;
requestCount: number;
}>();
const accountedRoutes = new Set<string>();
for (const [domain, routeNames] of domainToRoutes) {
for (const [domain, routeKeys] of domainToRoutes) {
const domainReqs = domainRequestTotals.get(domain) || 0;
let totalConns = 0;
let totalIn = 0;
let totalOut = 0;
for (const routeName of routeNames) {
accountedRoutes.add(routeName);
const conns = connectionsByRoute.get(routeName) || 0;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
const routeTotal = routeTotalRequests.get(routeName) || 0;
for (const routeKey of routeKeys) {
const conns = connectionsByRoute.get(routeKey) || 0;
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
const routeTotal = routeTotalRequests.get(routeKey) || 0;
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
totalConns += conns * share;
@@ -816,34 +815,11 @@ export class MetricsManager {
activeConnections: Math.round(totalConns),
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeNames.length,
routeCount: routeKeys.length,
requestCount: domainReqs,
});
}
// Include routes with no domain config (fallback: use route name)
for (const [routeName, activeConns] of connectionsByRoute) {
if (accountedRoutes.has(routeName)) continue;
if (routeDomains.has(routeName)) continue;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
const existing = domainAgg.get(routeName);
if (existing) {
existing.activeConnections += activeConns;
existing.bytesInPerSec += tp.in;
existing.bytesOutPerSec += tp.out;
existing.routeCount++;
} else {
domainAgg.set(routeName, {
activeConnections: activeConns,
bytesInPerSec: tp.in,
bytesOutPerSec: tp.out,
routeCount: 1,
requestCount: 0,
});
}
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
@@ -1015,4 +991,4 @@ export class MetricsManager {
return { queries };
}
}
}

View File

@@ -198,12 +198,11 @@ export class CertificateHandler {
try {
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
if (rustStatus) {
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
if (rustStatus.issuer) issuer = rustStatus.issuer;
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
status = rustStatus.status;
if (rustStatus.expiresAt > 0) {
expiryDate = new Date(rustStatus.expiresAt).toISOString();
}
if (rustStatus.source) issuer = rustStatus.source;
status = rustStatus.isValid ? 'valid' : 'expired';
}
} catch {
// Rust bridge may not support this command yet — ignore

View File

@@ -48,7 +48,7 @@ export class EmailOpsHandler {
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(dataArg.emailId);
const item = emailServer.getQueueItem(dataArg.emailId);
if (!item) {
return { success: false, error: 'Email not found in queue' };
@@ -82,22 +82,10 @@ export class EmailOpsHandler {
*/
private getAllQueueEmails(): interfaces.requests.IEmail[] {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
if (!emailServer) {
return [];
}
const queue = emailServer.deliveryQueue;
const queueMap = (queue as any).queue as Map<string, any>;
if (!queueMap) {
return [];
}
const emails: interfaces.requests.IEmail[] = [];
for (const [id, item] of queueMap.entries()) {
emails.push(this.mapQueueItemToEmail(item));
}
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
// Sort by createdAt descending (newest first)
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
@@ -110,12 +98,10 @@ export class EmailOpsHandler {
*/
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
if (!emailServer) {
return null;
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(emailId);
const item = emailServer.getQueueItem(emailId);
if (!item) {
return null;

View File

@@ -87,12 +87,12 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.updateRoute(dataArg.id, {
const result = await manager.updateRoute(dataArg.id, {
route: dataArg.route as any,
enabled: dataArg.enabled,
metadata: dataArg.metadata,
});
return { success: ok, message: ok ? undefined : 'Route not found' };
return result;
},
),
);
@@ -107,8 +107,7 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.deleteRoute(dataArg.id);
return { success: ok, message: ok ? undefined : 'Route not found' };
return manager.deleteRoute(dataArg.id);
},
),
);
@@ -123,8 +122,7 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Route not found' };
return manager.toggleRoute(dataArg.id, dataArg.enabled);
},
),
);

View File

@@ -530,13 +530,49 @@ export class StatsHandler {
nextRetry?: number;
}>;
}> {
// TODO: Implement actual queue status collection
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return {
pending: 0,
active: 0,
failed: 0,
retrying: 0,
items: [],
};
}
const queueStats = emailServer.getQueueStats();
const items = emailServer.getQueueItems()
.sort((a, b) => {
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
return right - left;
})
.slice(0, 50)
.map((item) => {
const emailLike = item.processingResult;
const recipients = Array.isArray(emailLike?.to)
? emailLike.to
: Array.isArray(emailLike?.email?.to)
? emailLike.email.to
: [];
const subject = emailLike?.subject || emailLike?.email?.subject || '';
return {
id: item.id,
recipient: recipients[0] || '',
subject,
status: item.status,
attempts: item.attempts,
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
};
});
return {
pending: 0,
active: 0,
failed: 0,
retrying: 0,
items: [],
pending: queueStats.status.pending,
active: queueStats.status.processing,
failed: queueStats.status.failed,
retrying: queueStats.status.deferred,
items,
};
}
@@ -600,4 +636,4 @@ export class StatsHandler {
],
};
}
}
}

View File

@@ -55,6 +55,8 @@ export class VpnManager {
private vpnServer?: plugins.smartvpn.VpnServer;
private clients: Map<string, VpnClientDoc> = new Map();
private serverKeys?: VpnServerKeysDoc;
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
constructor(config: IVpnManagerConfig) {
this.config = config;
@@ -88,6 +90,7 @@ export class VpnManager {
if (client.useHostIp) {
anyClientUsesHostIp = true;
}
this.normalizeClientRoutingSettings(client);
const entry: plugins.smartvpn.IClientEntry = {
clientId: client.clientId,
publicKey: client.noisePublicKey,
@@ -97,13 +100,12 @@ export class VpnManager {
assignedIp: client.assignedIp,
expiresAt: client.expiresAt,
security: this.buildClientSecurity(client),
useHostIp: client.useHostIp,
useDhcp: client.useDhcp,
staticIp: client.staticIp,
forceVlan: client.forceVlan,
vlanId: client.vlanId,
};
// Pass per-client bridge fields if present (for hybrid/bridge mode)
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
clientEntries.push(entry);
}
@@ -112,13 +114,15 @@ export class VpnManager {
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
let configuredMode = this.config.forwardingMode ?? 'socket';
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
if (anyClientUsesHostIp && configuredMode === 'socket') {
configuredMode = 'hybrid';
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
}
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
const isBridge = forwardingMode === 'bridge';
this.resolvedForwardingMode = forwardingMode;
this.forwardingModeOverride = undefined;
// Create and start VpnServer
this.vpnServer = new plugins.smartvpn.VpnServer({
@@ -143,7 +147,7 @@ export class VpnManager {
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: !isBridge,
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
serverEndpoint: this.config.serverEndpoint
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
@@ -189,6 +193,7 @@ export class VpnManager {
this.vpnServer.stop();
this.vpnServer = undefined;
}
this.resolvedForwardingMode = undefined;
logger.log('info', 'VPN server stopped');
}
@@ -213,14 +218,38 @@ export class VpnManager {
throw new Error('VPN server not running');
}
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
const doc = new VpnClientDoc();
doc.clientId = opts.clientId;
doc.enabled = true;
doc.targetProfileIds = opts.targetProfileIds;
doc.description = opts.description;
doc.destinationAllowList = opts.destinationAllowList;
doc.destinationBlockList = opts.destinationBlockList;
doc.useHostIp = opts.useHostIp;
doc.useDhcp = opts.useDhcp;
doc.staticIp = opts.staticIp;
doc.forceVlan = opts.forceVlan;
doc.vlanId = opts.vlanId;
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
this.normalizeClientRoutingSettings(doc);
const bundle = await this.vpnServer.createClient({
clientId: opts.clientId,
description: opts.description,
clientId: doc.clientId,
description: doc.description,
security: this.buildClientSecurity(doc),
useHostIp: doc.useHostIp,
useDhcp: doc.useDhcp,
staticIp: doc.staticIp,
forceVlan: doc.forceVlan,
vlanId: doc.vlanId,
});
// Override AllowedIPs with per-client values based on target profiles
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
bundle.wireguardConfig = bundle.wireguardConfig.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
@@ -228,40 +257,16 @@ export class VpnManager {
}
// Persist client entry (including WG private key for export/QR)
const doc = new VpnClientDoc();
doc.clientId = bundle.entry.clientId;
doc.enabled = bundle.entry.enabled ?? true;
doc.targetProfileIds = opts.targetProfileIds;
doc.description = bundle.entry.description;
doc.assignedIp = bundle.entry.assignedIp;
doc.noisePublicKey = bundle.entry.publicKey;
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
doc.expiresAt = bundle.entry.expiresAt;
if (opts.destinationAllowList !== undefined) {
doc.destinationAllowList = opts.destinationAllowList;
}
if (opts.destinationBlockList !== undefined) {
doc.destinationBlockList = opts.destinationBlockList;
}
if (opts.useHostIp !== undefined) {
doc.useHostIp = opts.useHostIp;
}
if (opts.useDhcp !== undefined) {
doc.useDhcp = opts.useDhcp;
}
if (opts.staticIp !== undefined) {
doc.staticIp = opts.staticIp;
}
if (opts.forceVlan !== undefined) {
doc.forceVlan = opts.forceVlan;
}
if (opts.vlanId !== undefined) {
doc.vlanId = opts.vlanId;
}
this.clients.set(doc.clientId, doc);
try {
await this.persistClient(doc);
@@ -276,12 +281,6 @@ export class VpnManager {
throw err;
}
// Sync per-client security to the running daemon
const security = this.buildClientSecurity(doc);
if (security.destinationPolicy) {
await this.vpnServer!.updateClient(doc.clientId, { security });
}
this.config.onClientChanged?.();
return bundle;
}
@@ -364,13 +363,13 @@ export class VpnManager {
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
this.normalizeClientRoutingSettings(client);
client.updatedAt = Date.now();
await this.persistClient(client);
// Sync per-client security to the running daemon
if (this.vpnServer) {
const security = this.buildClientSecurity(client);
await this.vpnServer.updateClient(clientId, { security });
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
}
this.config.onClientChanged?.();
@@ -478,26 +477,28 @@ export class VpnManager {
/**
* Build per-client security settings for the smartvpn daemon.
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
* TargetProfile direct IP:port targets extend the effective allow-list.
*/
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
const security: plugins.smartvpn.IClientSecurity = {};
const basePolicy = this.getBaseDestinationPolicy(client);
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
// Merge with per-client explicit allow list
const mergedAllowList = [
...(client.destinationAllowList || []),
...profileDirectTargets,
];
const mergedAllowList = this.mergeDestinationLists(
basePolicy.allowList,
client.destinationAllowList,
profileDirectTargets,
);
const mergedBlockList = this.mergeDestinationLists(
basePolicy.blockList,
client.destinationBlockList,
);
security.destinationPolicy = {
default: 'forceTarget' as const,
target: '127.0.0.1',
default: basePolicy.default,
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
allowList: mergedAllowList.length ? mergedAllowList : undefined,
blockList: client.destinationBlockList,
blockList: mergedBlockList.length ? mergedBlockList : undefined,
};
return security;
@@ -510,10 +511,7 @@ export class VpnManager {
public async refreshAllClientSecurity(): Promise<void> {
if (!this.vpnServer) return;
for (const client of this.clients.values()) {
const security = this.buildClientSecurity(client);
if (security.destinationPolicy) {
await this.vpnServer.updateClient(client.clientId, { security });
}
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
}
}
@@ -550,6 +548,7 @@ export class VpnManager {
private async loadPersistedClients(): Promise<void> {
const docs = await VpnClientDoc.findAll();
for (const doc of docs) {
this.normalizeClientRoutingSettings(doc);
this.clients.set(doc.clientId, doc);
}
if (this.clients.size > 0) {
@@ -557,6 +556,93 @@ export class VpnManager {
}
}
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
return this.resolvedForwardingMode
?? this.forwardingModeOverride
?? this.config.forwardingMode
?? 'socket';
}
private getDefaultDestinationPolicy(
forwardingMode: 'socket' | 'bridge' | 'hybrid',
useHostIp = false,
): plugins.smartvpn.IDestinationPolicy {
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
return { default: 'allow' };
}
return { default: 'forceTarget', target: '127.0.0.1' };
}
private getServerDestinationPolicy(
forwardingMode: 'socket' | 'bridge' | 'hybrid',
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
): plugins.smartvpn.IDestinationPolicy {
return this.config.destinationPolicy ?? fallbackPolicy;
}
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
if (this.config.destinationPolicy) {
return { ...this.config.destinationPolicy };
}
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
}
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
const merged = new Set<string>();
for (const list of lists) {
for (const entry of list || []) {
merged.add(entry);
}
}
return [...merged];
}
private normalizeClientRoutingSettings(
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
): void {
client.useHostIp = client.useHostIp === true;
if (!client.useHostIp) {
client.useDhcp = false;
client.staticIp = undefined;
client.forceVlan = false;
client.vlanId = undefined;
return;
}
client.useDhcp = client.useDhcp === true;
if (client.useDhcp) {
client.staticIp = undefined;
}
client.forceVlan = client.forceVlan === true;
if (!client.forceVlan) {
client.vlanId = undefined;
}
}
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
return {
description: client.description,
security: this.buildClientSecurity(client),
useHostIp: client.useHostIp,
useDhcp: client.useDhcp,
staticIp: client.staticIp,
forceVlan: client.forceVlan,
vlanId: client.vlanId,
};
}
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
if (!useHostIp || !this.vpnServer) return;
if (this.getResolvedForwardingMode() !== 'socket') return;
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
this.forwardingModeOverride = 'hybrid';
await this.stop();
await this.start();
}
private async persistClient(client: VpnClientDoc): Promise<void> {
await client.save();
}

View File

@@ -1,8 +1,8 @@
# @serve.zone/dcrouter-apiclient
A typed, object-oriented API client for DcRouter with a fluent builder pattern. 🔧
Typed, object-oriented API client for operating a running dcrouter instance. 🔧
Programmatically manage your DcRouter instance — routes, certificates, API tokens, remote ingress edges, RADIUS, email operations, and more — all with full TypeScript type safety and an intuitive OO interface.
Use this package when you want a clean TypeScript client instead of manually firing TypedRequest calls. It wraps the OpsServer API in resource managers and resource classes such as routes, certificates, tokens, edges, emails, stats, logs, config, and RADIUS.
## Issue Reporting and Security
@@ -14,7 +14,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter-apiclient
```
Or import directly from the main package:
Or import through the main package:
```typescript
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
@@ -23,239 +23,113 @@ import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
## Quick Start
```typescript
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
import { DcRouterApiClient } from '@serve.zone/dcrouter-apiclient';
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
});
// Authenticate
await client.login('admin', 'password');
// List routes
const { routes, warnings } = await client.routes.list();
console.log(`${routes.length} routes, ${warnings.length} warnings`);
const { routes } = await client.routes.list();
console.log(routes.map((route) => `${route.origin}:${route.name}`));
// Check health
const { health } = await client.stats.getHealth();
console.log(`Healthy: ${health.healthy}`);
await client.routes.build()
.setName('api-gateway')
.setMatch({ ports: 443, domains: ['api.example.com'] })
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
.save();
```
## Usage
## Authentication Modes
### 🔐 Authentication
| Mode | How it works |
| --- | --- |
| Admin login | Call `login(username, password)` and the client stores the returned identity for later requests |
| API token | Pass `apiToken` into the constructor for token-based automation |
```typescript
// Login with credentials — identity is stored and auto-injected into all subsequent requests
const identity = await client.login('admin', 'password');
// Verify current session
const { valid } = await client.verifyIdentity();
// Logout
await client.logout();
// Or use an API token for programmatic access (route management only)
const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
apiToken: 'dcr_your_token_here',
});
```
### 🌐 Routes — OO Resources + Builder
## Main Managers
Routes are returned as `Route` instances with methods for update, delete, toggle, and overrides:
| Manager | Purpose |
| --- | --- |
| `client.routes` | List routes and create API-managed routes |
| `client.certificates` | Inspect and operate on certificate records |
| `client.apiTokens` | Create, list, toggle, roll, revoke API tokens |
| `client.remoteIngress` | Manage registered remote ingress edges |
| `client.stats` | Read operational metrics and health data |
| `client.config` | Read current configuration view |
| `client.logs` | Read recent logs or stream them |
| `client.emails` | List emails and trigger resend flows |
| `client.radius` | Operate on RADIUS clients, VLANs, sessions, and accounting |
## Route Behavior
Routes are returned as `Route` instances with:
- `id`
- `name`
- `enabled`
- `origin`
Important behavior:
- API routes can be created, updated, deleted, and toggled.
- System routes can be listed and toggled, but not edited or deleted.
- A system route is any route whose `origin !== 'api'`.
```typescript
// List all routes (hardcoded + programmatic)
const { routes, warnings } = await client.routes.list();
const { routes } = await client.routes.list();
// Inspect a route
const route = routes[0];
console.log(route.name, route.source, route.enabled);
// Modify a programmatic route
await route.update({ name: 'renamed-route' });
await route.toggle(false);
await route.delete();
// Override a hardcoded route (disable it)
const hardcodedRoute = routes.find(r => r.source === 'hardcoded');
await hardcodedRoute.setOverride(false);
await hardcodedRoute.removeOverride();
for (const route of routes) {
if (route.origin !== 'api') {
await route.toggle(false);
}
}
```
**Builder pattern** for creating new routes:
## Builder Example
```typescript
const newRoute = await client.routes.build()
.setName('api-gateway')
.setMatch({ ports: 443, domains: ['api.example.com'] })
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
.setTls({ mode: 'terminate', certificate: 'auto' })
const route = await client.routes.build()
.setName('internal-app')
.setMatch({
ports: 80,
domains: ['internal.example.com'],
})
.setAction({
type: 'forward',
targets: [{ host: '127.0.0.1', port: 3000 }],
})
.setEnabled(true)
.save();
// Or use quick creation
const route = await client.routes.create(routeConfig);
await route.toggle(false);
```
### 🔑 API Tokens
```typescript
// List existing tokens
const tokens = await client.apiTokens.list();
// Create with builder
const token = await client.apiTokens.build()
.setName('ci-pipeline')
.setScopes(['routes:read', 'routes:write'])
.addScope('config:read')
.setExpiresInDays(90)
.save();
console.log(token.tokenValue); // Only available at creation time!
// Manage tokens
await token.toggle(false); // Disable
const newValue = await token.roll(); // Regenerate secret
await token.revoke(); // Delete
```
### 🔐 Certificates
## Example: Certificates and Stats
```typescript
const { certificates, summary } = await client.certificates.list();
console.log(`${summary.valid} valid, ${summary.expiring} expiring, ${summary.failed} failed`);
console.log(summary.valid, summary.failed);
// Operate on individual certificates
const cert = certificates[0];
await cert.reprovision();
const exported = await cert.export();
await cert.delete();
// Import a certificate
await client.certificates.import({
id: 'cert-id',
domainName: 'example.com',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 3600 * 1000,
privateKey: '...',
publicKey: '...',
csr: '...',
});
const health = await client.stats.getHealth();
const recentLogs = await client.logs.getRecent({ level: 'error', limit: 20 });
```
### 🌍 Remote Ingress
## What This Package Does Not Do
```typescript
// List edges and their statuses
const edges = await client.remoteIngress.list();
const statuses = await client.remoteIngress.getStatuses();
- It does not start dcrouter.
- It does not embed the dashboard.
- It does not replace the request interfaces package if you only need raw types.
// Create with builder
const edge = await client.remoteIngress.build()
.setName('edge-nyc-01')
.setListenPorts([80, 443])
.setAutoDerivePorts(true)
.setTags(['us-east'])
.save();
// Manage an edge
await edge.update({ name: 'edge-nyc-02' });
const newSecret = await edge.regenerateSecret();
const token = await edge.getConnectionToken();
await edge.delete();
```
### 📊 Statistics (Read-Only)
```typescript
const serverStats = await client.stats.getServer({ timeRange: '24h', includeHistory: true });
const emailStats = await client.stats.getEmail({ domain: 'example.com' });
const dnsStats = await client.stats.getDns();
const security = await client.stats.getSecurity({ includeDetails: true });
const connections = await client.stats.getConnections({ protocol: 'https' });
const queues = await client.stats.getQueues();
const health = await client.stats.getHealth(true);
const network = await client.stats.getNetwork();
const combined = await client.stats.getCombined({ server: true, email: true });
```
### ⚙️ Configuration & Logs
```typescript
// Read-only configuration
const config = await client.config.get();
const emailSection = await client.config.get('email');
// Logs
const { logs, total, hasMore } = await client.logs.getRecent({
level: 'error',
category: 'smtp',
limit: 50,
});
```
### 📧 Email Operations
```typescript
const emails = await client.emails.list();
const email = emails[0];
const detail = await email.getDetail();
await email.resend();
// Or use the manager directly
const detail2 = await client.emails.getDetail('email-id');
await client.emails.resend('email-id');
```
### 📡 RADIUS
```typescript
// Client management
const clients = await client.radius.clients.list();
await client.radius.clients.set({
name: 'switch-1',
ipRange: '192.168.1.0/24',
secret: 'shared-secret',
enabled: true,
});
await client.radius.clients.remove('switch-1');
// VLAN management
const { mappings, config: vlanConfig } = await client.radius.vlans.list();
await client.radius.vlans.set({ mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true });
const result = await client.radius.vlans.testAssignment('aa:bb:cc:dd:ee:ff');
await client.radius.vlans.updateConfig({ defaultVlan: 200 });
// Sessions
const { sessions } = await client.radius.sessions.list({ vlanId: 10 });
await client.radius.sessions.disconnect('session-id', 'Admin disconnect');
// Statistics & Accounting
const stats = await client.radius.getStatistics();
const summary = await client.radius.getAccountingSummary(startTime, endTime);
```
## API Surface
| Manager | Methods |
|---------|---------|
| `client.login()` / `logout()` / `verifyIdentity()` | Authentication |
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getRateLimits()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
| `client.config` | `get(section?)` |
| `client.logs` | `getRecent()`, `getStream()` |
| `client.emails` | `list()`, `getDetail()`, `resend()` → Email: `getDetail()`, `resend()` |
| `client.radius` | `.clients.list/set/remove()`, `.vlans.list/set/remove/updateConfig/testAssignment()`, `.sessions.list/disconnect()`, `getStatistics()`, `getAccountingSummary()` |
## Architecture
The client uses HTTP-based [TypedRequest](https://code.foss.global/api.global/typedrequest) for transport. All requests are sent as POST to `{baseUrl}/typedrequest`. Authentication (JWT identity and/or API token) is automatically injected into every request payload via `buildRequestPayload()`.
Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) hold a reference to the client and provide instance methods that fire the appropriate TypedRequest operations. Builder classes (`RouteBuilder`, `ApiTokenBuilder`, `RemoteIngressBuilder`) use fluent chaining and a terminal `.save()` method.
Use `@serve.zone/dcrouter` to run the server, `@serve.zone/dcrouter-web` for the dashboard bundle/components, and `@serve.zone/dcrouter-interfaces` for raw API contracts.
## License and Legal Information

View File

@@ -90,6 +90,7 @@ export interface IMergedRoute {
id: string;
enabled: boolean;
origin: 'config' | 'email' | 'dns' | 'api';
systemKey?: string;
createdAt?: number;
updatedAt?: number;
metadata?: IRouteMetadata;
@@ -132,6 +133,7 @@ export interface IRoute {
updatedAt: number;
createdBy: string;
origin: 'config' | 'email' | 'dns' | 'api';
systemKey?: string;
metadata?: IRouteMetadata;
}

View File

@@ -21,7 +21,7 @@ export interface ITargetProfile {
domains?: string[];
/** Specific IP:port targets this profile grants access to */
targets?: ITargetProfileTarget[];
/** Route references by stored route ID or route name */
/** Route references by stored route ID. Legacy route names are normalized when unique. */
routeRefs?: string[];
createdAt: number;
updatedAt: number;

View File

@@ -1,8 +1,8 @@
# @serve.zone/dcrouter-interfaces
TypeScript interfaces and type definitions for the DcRouter OpsServer API. 📡
Shared TypeScript request and data interfaces for dcrouter's OpsServer API. 📡
This module provides strongly-typed interfaces for communicating with the DcRouter OpsServer via [TypedRequest](https://code.foss.global/api.global/typedrequest). Use these interfaces for type-safe API interactions in your frontend applications or integration code.
This package is the contract layer for typed clients, frontend code, tests, or automation that talks to a running dcrouter instance through TypedRequest.
## Issue Reporting and Security
@@ -14,320 +14,79 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter-interfaces
```
Or import directly from the main package:
Or consume the same interfaces through the main package:
```typescript
import { data, requests } from '@serve.zone/dcrouter/interfaces';
```
## Usage
## What It Exports
The package exposes two namespaces from `index.ts`:
| Export | Purpose |
| --- | --- |
| `data` | Shared runtime-shaped types such as route data, auth identity, stats, domains, certificates, VPN, DNS, and email-domain data |
| `requests` | TypedRequest request and response contracts for every OpsServer endpoint |
## Example
```typescript
import * as typedrequest from '@api.global/typedrequest';
import { data, requests } from '@serve.zone/dcrouter-interfaces';
// Use data interfaces for type definitions
const identity: data.IIdentity = {
jwt: 'your-jwt-token',
userId: 'user-123',
name: 'Admin User',
expiresAt: Date.now() + 3600000,
role: 'admin'
jwt: 'jwt-token',
userId: 'admin-1',
name: 'Admin',
expiresAt: Date.now() + 60_000,
role: 'admin',
};
// Use request interfaces for API calls
import * as typedrequest from '@api.global/typedrequest';
const statsClient = new typedrequest.TypedRequest<requests.IReq_GetServerStatistics>(
'https://your-dcrouter:3000/typedrequest',
'getServerStatistics'
const request = new typedrequest.TypedRequest<requests.IReq_GetMergedRoutes>(
'https://dcrouter.example.com/typedrequest',
'getMergedRoutes',
);
const stats = await statsClient.fire({
identity,
includeHistory: true,
timeRange: '24h'
});
```
const response = await request.fire({ identity });
## Module Structure
### Data Interfaces (`data`)
Core data types used throughout the DcRouter system:
#### `IIdentity`
Authentication identity for API requests:
```typescript
interface IIdentity {
jwt: string; // JWT token
userId: string; // Unique user ID
name: string; // Display name
expiresAt: number; // Token expiration timestamp
role?: string; // User role (e.g., 'admin')
type?: string; // Identity type
for (const route of response.routes) {
console.log(route.id, route.origin, route.systemKey, route.enabled);
}
```
#### Statistics Interfaces
| Interface | Description |
|-----------|-------------|
| `IServerStats` | Uptime, memory, CPU, connection counts |
| `IEmailStats` | Sent/received/bounced/queued/failed, delivery & bounce rates |
| `IDnsStats` | Total queries, cache hits/misses, query types |
| `IRateLimitInfo` | Domain rate limit status (current rate, limit, remaining) |
| `ISecurityMetrics` | Blocked IPs, spam/malware/phishing counts |
| `IConnectionInfo` | Connection ID, remote address, protocol, state, bytes |
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
| `IRadiusStats` | Running, uptime, auth requests/accepts/rejects, sessions, data transfer |
| `IVpnStats` | Running, subnet, registered/connected clients, WireGuard port |
| `ILogEntry` | Timestamp, level, category, message, metadata |
## API Domains Covered
#### Route Management Interfaces
| Interface | Description |
|-----------|-------------|
| `IMergedRoute` | Combined route: routeConfig, source (hardcoded/programmatic), enabled, overridden |
| `IRouteWarning` | Merge warning: disabled-hardcoded, disabled-programmatic, orphaned-override |
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
| Domain | Examples |
| --- | --- |
| Auth | admin login, logout, identity verification |
| Routes | merged routes, create, update, delete, toggle |
| Access | API tokens, source profiles, target profiles, network targets |
| DNS and domains | providers, domains, DNS records |
| Certificates | overview, reprovision, import, export, delete, ACME config |
| Email | email operations, email domains |
| Remote ingress | edge registrations, status, connection tokens |
| VPN | clients, status, telemetry, lifecycle |
| RADIUS | clients, VLANs, sessions, accounting |
| Observability | stats, logs, health, configuration |
#### Security & Reference Interfaces
| Interface | Description |
|-----------|-------------|
| `ISecurityProfile` | Reusable security config: id, name, description, security (ipAllowList, ipBlockList, maxConnections, rateLimit, etc.), extendsProfiles |
| `INetworkTarget` | Reusable host:port destination: id, name, description, host (string or string[]), port |
| `IRouteMetadata` | Route-to-reference links: securityProfileRef, networkTargetRef, snapshot names, lastResolvedAt |
## Notable Data Types
#### Remote Ingress Interfaces
| Interface | Description |
|-----------|-------------|
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
| `IRouteVpn` | Route-level VPN config: `enabled`/`mandatory` flags and optional `allowedServerDefinedClientTags` |
| Type | Description |
| --- | --- |
| `data.IMergedRoute` | Route entry returned by route management, including `origin`, `enabled`, and optional `systemKey` |
| `data.IDcRouterRouteConfig` | dcrouter-flavored route config used across the stack |
| `data.IRouteMetadata` | Reference metadata connecting routes to source profiles or network targets |
| `data.IIdentity` | Admin identity used for authenticated requests |
| `data.IApiTokenInfo` | Public token metadata without the secret |
#### VPN Interfaces
| Interface | Description |
|-----------|-------------|
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
## When To Use This Package
### Request Interfaces (`requests`)
- Use it in custom dashboards or CLIs that call TypedRequest directly.
- Use it in tests that need strongly typed request payloads or response assertions.
- Use it when you want the API contract without pulling in the OO client.
TypedRequest interfaces for the OpsServer API, organized by domain:
#### 🔐 Authentication
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_AdminLoginWithUsernameAndPassword` | `adminLoginWithUsernameAndPassword` | Authenticate as admin |
| `IReq_AdminLogout` | `adminLogout` | End admin session |
| `IReq_VerifyIdentity` | `verifyIdentity` | Verify JWT token validity |
#### 📊 Statistics
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetServerStatistics` | `getServerStatistics` | Overall server stats |
| `IReq_GetEmailStatistics` | `getEmailStatistics` | Email throughput metrics |
| `IReq_GetDnsStatistics` | `getDnsStatistics` | DNS query stats |
| `IReq_GetRateLimitStatus` | `getRateLimitStatus` | Rate limit status |
| `IReq_GetSecurityMetrics` | `getSecurityMetrics` | Security event metrics |
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
| `IReq_GetNetworkStats` | `getNetworkStats` | Network throughput and connection analytics |
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request (server, email, DNS, security, network, RADIUS, VPN) |
#### ⚙️ Configuration
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetConfiguration` | `getConfiguration` | Current config (read-only) |
#### 📜 Logs
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetRecentLogs` | `getLogs` | Retrieve system logs |
| `IReq_GetLogStream` | `getLogStream` | Stream live logs |
#### 📧 Email Operations
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetAllEmails` | `getAllEmails` | List all emails |
| `IReq_GetEmailDetail` | `getEmailDetail` | Full detail for a specific email |
| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email |
#### 🛣️ Route Management
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetMergedRoutes` | `getMergedRoutes` | List all routes (hardcoded + programmatic) |
| `IReq_CreateRoute` | `createRoute` | Create a new programmatic route |
| `IReq_UpdateRoute` | `updateRoute` | Update a programmatic route |
| `IReq_DeleteRoute` | `deleteRoute` | Delete a programmatic route |
| `IReq_ToggleRoute` | `toggleRoute` | Enable/disable a programmatic route |
| `IReq_SetRouteOverride` | `setRouteOverride` | Override a hardcoded route |
| `IReq_RemoveRouteOverride` | `removeRouteOverride` | Remove a route override |
#### 🔑 API Token Management
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_CreateApiToken` | `createApiToken` | Create a new API token |
| `IReq_ListApiTokens` | `listApiTokens` | List all tokens |
| `IReq_RevokeApiToken` | `revokeApiToken` | Revoke (delete) a token |
| `IReq_RollApiToken` | `rollApiToken` | Regenerate token secret |
| `IReq_ToggleApiToken` | `toggleApiToken` | Enable/disable a token |
#### 🔐 Certificates
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetCertificateOverview` | `getCertificateOverview` | Domain-centric certificate status |
| `IReq_ReprovisionCertificate` | `reprovisionCertificate` | Reprovision by route name (legacy) |
| `IReq_ReprovisionCertificateDomain` | `reprovisionCertificateDomain` | Reprovision by domain (preferred) |
| `IReq_ImportCertificate` | `importCertificate` | Import a certificate |
| `IReq_ExportCertificate` | `exportCertificate` | Export a certificate |
| `IReq_DeleteCertificate` | `deleteCertificate` | Delete a certificate |
#### Certificate Types
```typescript
type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
interface ICertificateInfo {
domain: string;
routeNames: string[];
status: TCertificateStatus;
source: TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
expiryDate?: string;
issuer?: string;
issuedAt?: string;
error?: string;
canReprovision: boolean;
backoffInfo?: {
failures: number;
retryAfter?: string;
lastError?: string;
};
}
```
#### 🌍 Remote Ingress
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_CreateRemoteIngress` | `createRemoteIngress` | Register a new edge node |
| `IReq_DeleteRemoteIngress` | `deleteRemoteIngress` | Remove an edge registration |
| `IReq_UpdateRemoteIngress` | `updateRemoteIngress` | Update edge settings |
| `IReq_RegenerateRemoteIngressSecret` | `regenerateRemoteIngressSecret` | Issue a new secret |
| `IReq_GetRemoteIngresses` | `getRemoteIngresses` | List all edge registrations |
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
#### 🔐 VPN
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
#### 📡 RADIUS
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetRadiusClients` | `getRadiusClients` | List NAS clients |
| `IReq_SetRadiusClient` | `setRadiusClient` | Add/update a NAS client |
| `IReq_RemoveRadiusClient` | `removeRadiusClient` | Remove a NAS client |
| `IReq_GetVlanMappings` | `getVlanMappings` | List VLAN mappings |
| `IReq_SetVlanMapping` | `setVlanMapping` | Add/update VLAN mapping |
| `IReq_RemoveVlanMapping` | `removeVlanMapping` | Remove VLAN mapping |
| `IReq_TestVlanAssignment` | `testVlanAssignment` | Test what VLAN a MAC gets |
| `IReq_GetRadiusSessions` | `getRadiusSessions` | List active sessions |
| `IReq_DisconnectRadiusSession` | `disconnectRadiusSession` | Force disconnect |
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
#### 🛡️ Security Profiles
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetSecurityProfiles` | `getSecurityProfiles` | List all security profiles |
| `IReq_GetSecurityProfile` | `getSecurityProfile` | Get a single profile by ID |
| `IReq_CreateSecurityProfile` | `createSecurityProfile` | Create a reusable security profile |
| `IReq_UpdateSecurityProfile` | `updateSecurityProfile` | Update a profile (propagates to routes) |
| `IReq_DeleteSecurityProfile` | `deleteSecurityProfile` | Delete a profile (with optional force) |
| `IReq_GetSecurityProfileUsage` | `getSecurityProfileUsage` | Get routes referencing a profile |
#### 🎯 Network Targets
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetNetworkTargets` | `getNetworkTargets` | List all network targets |
| `IReq_GetNetworkTarget` | `getNetworkTarget` | Get a single target by ID |
| `IReq_CreateNetworkTarget` | `createNetworkTarget` | Create a reusable host:port target |
| `IReq_UpdateNetworkTarget` | `updateNetworkTarget` | Update a target (propagates to routes) |
| `IReq_DeleteNetworkTarget` | `deleteNetworkTarget` | Delete a target (with optional force) |
| `IReq_GetNetworkTargetUsage` | `getNetworkTargetUsage` | Get routes referencing a target |
## Example: Full API Integration
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
```typescript
import * as typedrequest from '@api.global/typedrequest';
import { data, requests } from '@serve.zone/dcrouter-interfaces';
// 1. Login
const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>(
'https://your-dcrouter:3000/typedrequest',
'adminLoginWithUsernameAndPassword'
);
const loginResponse = await loginClient.fire({
username: 'admin',
password: 'your-password'
});
const identity = loginResponse.identity;
// 2. Fetch combined metrics
const metricsClient = new typedrequest.TypedRequest<requests.IReq_GetCombinedMetrics>(
'https://your-dcrouter:3000/typedrequest',
'getCombinedMetrics'
);
const metrics = await metricsClient.fire({ identity });
console.log('Server:', metrics.metrics.server);
console.log('Email:', metrics.metrics.email);
// 3. Check certificate status
const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOverview>(
'https://your-dcrouter:3000/typedrequest',
'getCertificateOverview'
);
const certs = await certClient.fire({ identity });
console.log(`Certificates: ${certs.summary.valid} valid, ${certs.summary.failed} failed`);
// 4. List remote ingress edges
const edgesClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngresses>(
'https://your-dcrouter:3000/typedrequest',
'getRemoteIngresses'
);
const edges = await edgesClient.fire({ identity });
console.log('Registered edges:', edges.edges.length);
// 5. Generate a connection token for an edge
const tokenClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngressConnectionToken>(
'https://your-dcrouter:3000/typedrequest',
'getRemoteIngressConnectionToken'
);
const tokenResponse = await tokenClient.fire({ identity, edgeId: edges.edges[0].id });
console.log('Connection token:', tokenResponse.token);
```
If you want a higher-level client with managers and resource classes, use `@serve.zone/dcrouter-apiclient` instead.
## License and Legal Information

View File

@@ -21,6 +21,57 @@ export interface IMigrationRunner {
run(): Promise<IMigrationRunResult>;
}
async function migrateTargetProfileTargetHosts(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}): Promise<void> {
const collection = ctx.mongo!.collection('TargetProfileDoc');
const cursor = collection.find({ 'targets.host': { $exists: true } });
let migrated = 0;
for await (const doc of cursor) {
const targets = ((doc as any).targets || []).map((target: any) => {
if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) {
const { host, ...rest } = target;
return { ...rest, ip: host };
}
return target;
});
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
migrated++;
}
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
}
async function backfillSystemRouteKeys(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}): Promise<void> {
const collection = ctx.mongo!.collection('RouteDoc');
const cursor = collection.find({
origin: { $in: ['config', 'email', 'dns'] },
systemKey: { $exists: false },
'route.name': { $type: 'string' },
});
let migrated = 0;
for await (const doc of cursor) {
const origin = typeof (doc as any).origin === 'string' ? (doc as any).origin : undefined;
const routeName = typeof (doc as any).route?.name === 'string' ? (doc as any).route.name.trim() : '';
if (!origin || !routeName) continue;
await collection.updateOne(
{ _id: (doc as any)._id },
{ $set: { systemKey: `${origin}:${routeName}` } },
);
migrated++;
}
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
}
/**
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
*
@@ -48,23 +99,7 @@ export async function createMigrationRunner(
.step('rename-target-profile-host-to-ip')
.from('13.0.11').to('13.1.0')
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
.up(async (ctx) => {
const collection = ctx.mongo!.collection('targetprofiledoc');
const cursor = collection.find({ 'targets.host': { $exists: true } });
let migrated = 0;
for await (const doc of cursor) {
const targets = ((doc as any).targets || []).map((t: any) => {
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
const { host, ...rest } = t;
return { ...rest, ip: host };
}
return t;
});
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
migrated++;
}
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
})
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
.step('rename-domain-source-manual-to-dcrouter')
.from('13.1.0').to('13.8.1')
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
@@ -120,6 +155,18 @@ export async function createMigrationRunner(
await db.collection('RouteOverrideDoc').drop();
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
}
})
.step('repair-target-profile-ip-migration')
.from('13.16.0').to('13.17.4')
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
.up(async (ctx) => {
await migrateTargetProfileTargetHosts(ctx);
})
.step('backfill-system-route-keys')
.from('13.17.4').to('13.18.0')
.description('Backfill RouteDoc.systemKey for persisted config/email/dns routes')
.up(async (ctx) => {
await backfillSystemRouteKeys(ctx);
});
return migration;

67
ts_migrations/readme.md Normal file
View File

@@ -0,0 +1,67 @@
# @serve.zone/dcrouter-migrations
Migration runner package for dcrouter's smartdata-backed persistence layer. 🧱
This package provides the startup migration chain that upgrades dcrouter data across releases before the application reads from the database.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## What It Exports
| Export | Purpose |
| --- | --- |
| `createMigrationRunner(db, targetVersion)` | Builds the dcrouter SmartMigration runner for the current application version |
| `IMigrationRunner` | Small interface describing the runner's `run()` method |
| `IMigrationRunResult` | Logged result shape used after execution |
## Usage
```typescript
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
const migration = await createMigrationRunner(db, '13.18.0');
const result = await migration.run();
console.log(result.currentVersionBefore, result.currentVersionAfter);
```
## What These Migrations Handle
The migration chain currently covers dcrouter-specific storage transitions such as:
- target profile target field renames
- domain and DNS record source renames
- route collection unification into `RouteDoc`
- persisted route metadata backfills such as `origin` and `systemKey`
## Important Behavior
- fresh installs are stamped directly to the current target version
- migration steps are registered in strict version order
- migrations run before services load DB-backed state
- route-related migrations use smartdata collection names exactly as declared in code
If you are embedding dcrouter's DB layer outside the main runtime, run this package before any feature code assumes the latest schema.
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.17.1',
version: '13.19.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -2150,7 +2150,7 @@ export const updateRouteAction = routeManagementStatePart.createAction<{
interfaces.requests.IReq_UpdateRoute
>('/typedrequest', 'updateRoute');
await request.fire({
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
route: dataArg.route,
@@ -2158,6 +2158,10 @@ export const updateRouteAction = routeManagementStatePart.createAction<{
metadata: dataArg.metadata,
});
if (!response.success) {
throw new Error(response.message || 'Failed to update route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) {
return {
@@ -2177,11 +2181,15 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
interfaces.requests.IReq_DeleteRoute
>('/typedrequest', 'deleteRoute');
await request.fire({
const response = await request.fire({
identity: context.identity!,
id: routeId,
});
if (!response.success) {
throw new Error(response.message || 'Failed to delete route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) {
return {
@@ -2204,12 +2212,16 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
interfaces.requests.IReq_ToggleRoute
>('/typedrequest', 'toggleRoute');
await request.fire({
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
enabled: dataArg.enabled,
});
if (!response.success) {
throw new Error(response.message || 'Failed to toggle route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) {
return {
@@ -2765,4 +2777,4 @@ startAutoRefresh();
// Connect TypedSocket if already logged in (e.g., persistent session)
if (loginStatePart.getState()!.isLoggedIn) {
connectSocket();
}
}

View File

@@ -374,7 +374,7 @@ export class OpsViewNetworkActivity extends DeesElement {
type: 'number',
icon: 'lucide:Plug',
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: [
{
name: 'View Details',

View File

@@ -227,10 +227,10 @@ export class OpsViewRoutes extends DeesElement {
></dees-statsgrid>
<dees-input-multitoggle
class="routeFilterToggle"
.type=${'single'}
.options=${['User Routes', 'System Routes']}
.selectedOption=${this.routeFilter}
@change=${(e: any) => { this.routeFilter = e.target.value || e.target.selectedOption; }}
></dees-input-multitoggle>
${warnings.length > 0
@@ -272,15 +272,13 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail;
if (!clickedRoute) return;
// Find the corresponding merged route
const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name,
);
const merged = this.findMergedRoute(clickedRoute);
if (!merged) return;
const { DeesModal } = await import('@design.estate/dees-catalog');
const meta = merged.metadata;
const isSystemManaged = this.isSystemManagedRoute(merged);
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
@@ -288,6 +286,7 @@ export class OpsViewRoutes extends DeesElement {
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.id}</code></p>
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div>
@@ -304,25 +303,29 @@ export class OpsViewRoutes extends DeesElement {
await modalArg.destroy();
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
action: async (modalArg: any) => {
await modalArg.destroy();
this.showEditRouteDialog(merged);
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction,
merged.id,
);
await modalArg.destroy();
},
},
...(!isSystemManaged
? [
{
name: 'Edit',
iconName: 'lucide:pencil',
action: async (modalArg: any) => {
await modalArg.destroy();
this.showEditRouteDialog(merged);
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction,
merged.id,
);
await modalArg.destroy();
},
},
]
: []),
{
name: 'Close',
iconName: 'lucide:x',
@@ -336,10 +339,9 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail;
if (!clickedRoute) return;
const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name,
);
const merged = this.findMergedRoute(clickedRoute);
if (!merged) return;
if (this.isSystemManagedRoute(merged)) return;
this.showEditRouteDialog(merged);
}
@@ -348,10 +350,9 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail;
if (!clickedRoute) return;
const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name,
);
const merged = this.findMergedRoute(clickedRoute);
if (!merged) return;
if (this.isSystemManagedRoute(merged)) return;
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
@@ -675,7 +676,32 @@ export class OpsViewRoutes extends DeesElement {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
private findMergedRoute(clickedRoute: { id?: string; name?: string }): interfaces.data.IMergedRoute | undefined {
if (clickedRoute.id) {
const routeById = this.routeState.mergedRoutes.find((mr) => mr.id === clickedRoute.id);
if (routeById) return routeById;
}
if (clickedRoute.name) {
return this.routeState.mergedRoutes.find((mr) => mr.route.name === clickedRoute.name);
}
return undefined;
}
private isSystemManagedRoute(merged: interfaces.data.IMergedRoute): boolean {
return merged.origin !== 'api';
}
async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
const toggle = this.shadowRoot!.querySelector('.routeFilterToggle') as any;
if (toggle) {
const sub = toggle.changeSubject.subscribe(() => {
this.routeFilter = toggle.selectedOption;
});
this.rxSubscriptions.push(sub);
}
}
}

View File

@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
: '-',
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
: '-',
Created: new Date(profile.createdAt).toLocaleDateString(),
})}
@@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
`;
}
private getRouteCandidates() {
private getRouteChoices() {
const routeState = appstate.routeManagementStatePart.getState();
const routes = routeState?.mergedRoutes || [];
return routes
.filter((mr) => mr.route.name)
.map((mr) => ({ viewKey: mr.route.name! }));
.filter((mr) => mr.route.name && mr.id)
.map((mr) => ({
routeId: mr.id!,
routeName: mr.route.name!,
label: `${mr.route.name} (${mr.id})`,
}));
}
private getRouteCandidates() {
return this.getRouteChoices().map((route) => ({ viewKey: route.label }));
}
private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined {
if (!routeRefs?.length) return undefined;
const routeChoices = this.getRouteChoices();
const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label]));
const routeByName = new Map<string, string[]>();
for (const route of routeChoices) {
const labels = routeByName.get(route.routeName) || [];
labels.push(route.label);
routeByName.set(route.routeName, labels);
}
return routeRefs.map((routeRef) => {
const routeLabel = routeById.get(routeRef);
if (routeLabel) return routeLabel;
const labelsForName = routeByName.get(routeRef) || [];
if (labelsForName.length === 1) return labelsForName[0];
return routeRef;
});
}
private resolveRouteLabelsToRefs(routeRefs: string[]): string[] {
if (!routeRefs.length) return [];
const labelToId = new Map(
this.getRouteChoices().map((route) => [route.label, route.routeId]),
);
return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef);
}
private formatRouteRef(routeRef: string): string {
return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef;
}
private async ensureRoutesLoaded() {
@@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
const routeRefs = this.resolveRouteLabelsToRefs(
Array.isArray(data.routeRefs) ? data.routeRefs : [],
);
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name),
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains || [];
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
const currentRouteRefs = profile.routeRefs || [];
const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || [];
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
@@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
const routeRefs = this.resolveRouteLabelsToRefs(
Array.isArray(data.routeRefs) ? data.routeRefs : [],
);
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id,
@@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.routeRefs?.length
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
? profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)
: '-'}
</div>
</div>

View File

@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
if (hostIpGroup) hostIpGroup.style.display = show;
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
@@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
if (!form) return;
const data = await form.collectFormData();
if (!data.clientId) return;
const targetProfileIds = this.resolveProfileNamesToIds(
const targetProfileIds = this.resolveProfileLabelsToIds(
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
);
@@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
description: data.description || undefined,
targetProfileIds,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
useHostIp,
useDhcp,
staticIp,
forceVlan: forceVlan || undefined,
forceVlan,
vlanId,
destinationAllowList,
destinationBlockList,
@@ -485,7 +485,7 @@ export class OpsViewVpn extends DeesElement {
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
` : ''}
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
${client.useHostIp ? html`
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
@@ -649,7 +649,7 @@ export class OpsViewVpn extends DeesElement {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? '';
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
const profileCandidates = this.getTargetProfileCandidates();
const currentUseHostIp = client.useHostIp ?? false;
const currentUseDhcp = client.useDhcp ?? false;
@@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const targetProfileIds = this.resolveProfileNamesToIds(
const targetProfileIds = this.resolveProfileLabelsToIds(
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
);
@@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
description: data.description || undefined,
targetProfileIds,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
useHostIp,
useDhcp,
staticIp,
forceVlan: forceVlan || undefined,
forceVlan,
vlanId,
destinationAllowList,
destinationBlockList,
@@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
}
/**
* Build autocomplete candidates from loaded target profiles.
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
* Build stable profile labels for list inputs.
*/
private getTargetProfileCandidates() {
private getTargetProfileChoices() {
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
const nameCounts = new Map<string, number>();
for (const profile of profiles) {
nameCounts.set(profile.name, (nameCounts.get(profile.name) || 0) + 1);
}
return profiles.map((profile) => ({
id: profile.id,
label: (nameCounts.get(profile.name) || 0) > 1
? `${profile.name} (${profile.id})`
: profile.name,
}));
}
private getTargetProfileCandidates() {
return this.getTargetProfileChoices().map((profile) => ({ viewKey: profile.label }));
}
/**
* Convert profile IDs to profile names (for populating edit form values).
* Convert profile IDs to form labels (for populating edit form values).
*/
private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
if (!ids?.length) return undefined;
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
const choices = this.getTargetProfileChoices();
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
return ids.map((id) => {
const profile = profiles.find((p) => p.id === id);
return profile?.name || id;
return labelsById.get(id) || id;
});
}
/**
* Convert profile names back to IDs (for saving form data).
* Uses the dees-input-list candidates' payload when available.
* Convert profile form labels back to IDs.
*/
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
if (!names.length) return undefined;
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
return names
.map((name) => {
const profile = profiles.find((p) => p.name === name);
return profile?.id;
})
private resolveProfileLabelsToIds(labels: string[]): string[] {
if (!labels.length) return [];
const labelsToIds = new Map(
this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
);
return labels
.map((label) => labelsToIds.get(label))
.filter((id): id is string => !!id);
}
}

View File

@@ -1,273 +1,72 @@
# @serve.zone/dcrouter-web
Web-based Operations Dashboard for DcRouter. 🖥️
Browser UI package for dcrouter's operations dashboard. 🖥️
A modern, reactive web application for monitoring and managing your DcRouter instance in real-time. Built with web components using [@design.estate/dees-element](https://code.foss.global/design.estate/dees-element) and [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog).
This package contains the browser entrypoint, app state, router, and web components that power the Ops dashboard served by dcrouter.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Features
## What Is In Here
### 🔐 Secure Authentication
- JWT-based login with persistent sessions (IndexedDB)
- Automatic session expiry detection and cleanup
- Secure username/password authentication
| Path | Purpose |
| --- | --- |
| `index.ts` | Browser entrypoint that initializes routing and renders `<ops-dashboard>` |
| `appstate.ts` | Central reactive state and action definitions |
| `router.ts` | URL-based dashboard routing |
| `elements/` | Dashboard views and reusable UI pieces |
### 📊 Overview Dashboard
- Real-time server statistics (CPU, memory, uptime)
- Active connection counts and email throughput
- DNS query metrics and RADIUS session tracking
- Auto-refreshing with configurable intervals
## Main Views
### 🌐 Network View
- Active connection monitoring with real-time data from SmartProxy
- Top connected IPs with connection counts and percentages
- Throughput rates (inbound/outbound in kbit/s, Mbit/s, Gbit/s)
- Traffic chart with selectable time ranges
The dashboard currently includes views for:
### 📧 Email Management
- **Queued** — Emails pending delivery with queue position
- **Sent** — Successfully delivered emails with timestamps
- **Failed** — Failed emails with resend capability
- **Security** — Security incidents from email processing
- Bounce record management and suppression list controls
- overview and configuration
- network activity and route management
- source profiles, target profiles, and network targets
- email activity and email domains
- DNS providers, domains, DNS records, and certificates
- API tokens and users
- VPN, remote ingress, logs, and security views
### 🔐 Certificate Management
- Domain-centric certificate overview with status indicators
- Certificate source tracking (ACME, provision function, static)
- Expiry date monitoring and alerts
- Per-domain backoff status for failed provisions
- One-click reprovisioning per domain
- Certificate import, export, and deletion
## Route Management UX
### 🌍 Remote Ingress Management
- Edge node registration with name, ports, and tags
- Real-time connection status (connected/disconnected/disabled)
- Public IP and active tunnel count per edge
- Auto-derived port display with manual/derived breakdown
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
- Enable/disable, edit, secret regeneration, and delete actions
The web UI reflects dcrouter's current route ownership model:
### 🔐 VPN Management
- VPN server status with forwarding mode, subnet, and WireGuard port
- Client registration table with create, enable/disable, and delete actions
- WireGuard config download, clipboard copy, and **QR code display** on client creation
- QR code export for existing clients — scan with WireGuard mobile app (iOS/Android)
- Per-client telemetry (bytes sent/received, keepalives)
- Server public key display for manual client configuration
- system routes are shown separately from user routes
- system routes are visible and toggleable
- system routes are not directly editable or deletable
- API routes are fully managed through the route-management forms
### 📜 Log Viewer
- Real-time log streaming
- Filter by log level (error, warning, info, debug)
- Search and time-range selection
## How It Talks To dcrouter
### 🛣️ Route & API Token Management
- Programmatic route CRUD with enable/disable and override controls
- API token creation, revocation, and scope management
- Routes tab and API Tokens tab in unified view
The frontend uses TypedRequest and shared interfaces from `@serve.zone/dcrouter-interfaces`.
### 🛡️ Security Profiles & Network Targets
- Create, edit, and delete reusable security profiles (IP allow/block lists, rate limits, max connections)
- Create, edit, and delete reusable network targets (host:port destinations)
- In-row and context menu actions for quick editing
- Changes propagate automatically to all referencing routes
State actions in `appstate.ts` fetch and mutate:
### ⚙️ Configuration
- Read-only display of current system configuration
- Status badges for boolean values (enabled/disabled)
- Array values displayed as pills with counts
- Section icons and formatted byte/time values
- stats and health
- logs
- routes and tokens
- certificates and ACME config
- DNS providers, domains, and records
- email domains and email operations
- VPN, remote ingress, and RADIUS data
### 🛡️ Security Dashboard
- IP reputation monitoring
- Rate limit status across domains
- Blocked connection tracking
- Security event timeline
## Development Notes
## Architecture
### Technology Stack
| Layer | Package | Purpose |
|-------|---------|---------|
| **Components** | `@design.estate/dees-element` | Web component framework (lit-element based) |
| **UI Kit** | `@design.estate/dees-catalog` | Pre-built components (tables, charts, forms, app shell) |
| **State** | `@push.rocks/smartstate` | Reactive state management with persistent/soft modes |
| **Routing** | Client-side router | URL-synchronized view navigation |
| **API** | `@api.global/typedrequest` | Type-safe communication with OpsServer |
| **Types** | `@serve.zone/dcrouter-interfaces` | Shared TypedRequest interface definitions |
### Component Structure
```
ts_web/
├── index.ts # Entry point — renders <ops-dashboard>
├── appstate.ts # State management (all state parts + actions)
├── router.ts # Client-side routing (AppRouter)
├── plugins.ts # Dependency imports
└── elements/
├── ops-dashboard.ts # Main app shell
├── ops-view-overview.ts # Overview statistics
├── ops-view-network.ts # Network monitoring
├── ops-view-emails.ts # Email queue management
├── ops-view-certificates.ts # Certificate overview & reprovisioning
├── ops-view-remoteingress.ts # Remote ingress edge management
├── ops-view-vpn.ts # VPN client management
├── ops-view-logs.ts # Log viewer
├── ops-view-routes.ts # Route & API token management
├── ops-view-config.ts # Configuration display
├── ops-view-security.ts # Security dashboard
└── shared/
├── css.ts # Shared styles
└── ops-sectionheading.ts # Section heading component
```
### State Management
The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled actions with `autoPause: 'visibility'`, and batched updates:
| State Part | Mode | Description |
|-----------|------|-------------|
| `loginStatePart` | Persistent (IndexedDB) | JWT identity and login status |
| `statsStatePart` | Soft (memory) | Server, email, DNS, security, RADIUS, VPN metrics |
| `configStatePart` | Soft | Current system configuration |
| `uiStatePart` | Soft | Active view, sidebar, auto-refresh, theme |
| `logStatePart` | Soft | Recent logs, streaming status, filters |
| `networkStatePart` | Soft | Connections, IPs, throughput rates |
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
### Tab Visibility Optimization
The dashboard automatically pauses all background activity when the browser tab is hidden and resumes when visible:
- **Auto-refresh polling** uses `createScheduledAction` with `autoPause: 'visibility'` — stops HTTP requests while the tab is sleeping
- **In-flight guard** prevents concurrent refresh requests from piling up
- **WebSocket connection** disconnects when hidden and reconnects when visible, preventing log entry accumulation
- **Network traffic timer** pauses chart updates when the tab is backgrounded
- **Log entry batching** — incoming WebSocket log pushes are buffered and flushed once per animation frame to avoid per-entry re-renders
### Actions
```typescript
// Authentication
loginAction(username, password) // JWT login
logoutAction() // Clear session
// Data fetching (auto-refresh compatible)
fetchAllStatsAction() // Server + email + DNS + security stats
fetchConfigurationAction() // System configuration
fetchRecentLogsAction() // Log entries
fetchNetworkStatsAction() // Connection + throughput data
// Email operations
fetchQueuedEmailsAction() // Pending emails
fetchSentEmailsAction() // Delivered emails
fetchFailedEmailsAction() // Failed emails
fetchSecurityIncidentsAction() // Security events
fetchBounceRecordsAction() // Bounce records
resendEmailAction(emailId) // Re-queue failed email
removeFromSuppressionAction(email) // Remove from suppression list
// Certificates
fetchCertificateOverviewAction() // All certificates with summary
reprovisionCertificateAction(domain) // Reprovision a certificate
deleteCertificateAction(domain) // Delete a certificate
importCertificateAction(cert) // Import a certificate
fetchCertificateExport(domain) // Export (standalone function)
// Remote Ingress
fetchRemoteIngressAction() // Edges + statuses
createRemoteIngressAction(data) // Create new edge
updateRemoteIngressAction(data) // Update edge settings
deleteRemoteIngressAction(id) // Remove edge
regenerateRemoteIngressSecretAction(id) // New secret
toggleRemoteIngressAction(id, enabled) // Enable/disable
clearNewEdgeSecretAction() // Dismiss secret banner
fetchConnectionToken(edgeId) // Get connection token (standalone function)
// VPN
fetchVpnAction() // Clients + server status
createVpnClientAction(data) // Create new VPN client
deleteVpnClientAction(clientId) // Remove VPN client
toggleVpnClientAction(id, enabled) // Enable/disable
clearNewClientConfigAction() // Dismiss config banner
```
### Client-Side Routing
```
/overview → Overview dashboard
/network → Network monitoring
/emails → Email management
/emails/queued → Queued emails
/emails/sent → Sent emails
/emails/failed → Failed emails
/emails/security → Security incidents
/certificates → Certificate management
/remoteingress → Remote ingress edge management
/vpn → VPN client management
/routes → Route & API token management
/logs → Log viewer
/configuration → System configuration
/security → Security dashboard
```
URL state is synchronized with the UI — bookmarking and deep linking fully supported.
## Development
### Running Locally
Start DcRouter with OpsServer enabled:
```typescript
import { DcRouter } from '@serve.zone/dcrouter';
const router = new DcRouter({
// OpsServer starts automatically on port 3000
smartProxyConfig: { routes: [/* your routes */] }
});
await router.start();
// Dashboard at http://localhost:3000
```
### Building
The browser bundle is built from this package and served by the main dcrouter package.
```bash
# Build the bundle
pnpm run bundle
# Watch for development (auto-rebuild + restart)
pnpm run watch
```
The bundle is output to `./dist_serve/bundle.js` and served by the OpsServer.
The generated bundle is written into `dist_serve/` by the main build pipeline.
### Adding a New View
## When To Use This Package
1. Create a view component in `elements/`:
```typescript
import { DeesElement, customElement, html, css } from '@design.estate/dees-element';
@customElement('ops-view-myview')
export class OpsViewMyView extends DeesElement {
public static styles = [css`:host { display: block; padding: 24px; }`];
public render() {
return html`<ops-sectionheading>My View</ops-sectionheading>`;
}
}
```
2. Add it to the dashboard tabs in `ops-dashboard.ts`
3. Add the route in `router.ts`
4. Add any state management in `appstate.ts`
- Use it if you want the dashboard frontend as a package/module boundary.
- Use the main `@serve.zone/dcrouter` package if you want the server that actually serves this UI.
## License and Legal Information