Compare commits

...

18 Commits

Author SHA1 Message Date
4b928b038e v13.20.2
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 14:28:19 +00:00
a466b88408 fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates 2026-04-17 14:28:19 +00:00
e26ea9e114 v13.20.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 13:43:13 +00:00
c5ca95b6f5 fix(docs): refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance 2026-04-17 13:43:13 +00:00
1f25ca4095 v13.20.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 06:17:49 +00:00
2891e5d3ee feat(routes): add remote ingress controls and preserve-port targeting for route configuration 2026-04-17 06:17:49 +00:00
152110c877 v13.19.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-16 22:21:07 +00:00
d780e02928 fix(routes): preserve inline target ports when clearing network target references 2026-04-16 22:21:07 +00:00
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
39 changed files with 2722 additions and 2893 deletions

View File

@@ -1,5 +1,68 @@
# Changelog # Changelog
## 2026-04-17 - 13.20.2 - fix(vpn)
handle VPN forwarding mode downgrades and support runtime VPN config updates
- restart the VPN server back to socket mode when host-IP clients are removed while preserving explicit hybrid mode
- allow DcRouter to update VPN configuration at runtime and refresh route allow-list resolution without recreating the router
- improve VPN operations UI target profile rendering and loading behavior for create and edit flows
## 2026-04-17 - 13.20.1 - fix(docs)
refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance
- Reworks the main README with updated positioning, quick-start examples, route ownership guidance, configuration notes, automation examples, and OCI bootstrap details
- Expands package-specific readmes for the runtime, API client, interfaces, migrations, and web dashboard to better describe exports, behavior, and usage
- Standardizes documentation references such as subpath import guidance and LICENSE link casing across readmes
## 2026-04-17 - 13.20.0 - feat(routes)
add remote ingress controls and preserve-port targeting for route configuration
- Allow route updates to remove optional top-level properties by treating null values like remoteIngress as explicit clears.
- Add route form support for preserving the matched incoming port when forwarding to backend targets.
- Add remote ingress enablement and edge filter controls to route create/edit views.
- Cover remoteIngress removal behavior with a runtime route manager test.
## 2026-04-16 - 13.19.1 - fix(routes)
preserve inline target ports when clearing network target references
- Normalize route metadata so empty reference fields are removed instead of persisted.
- Allow the routes UI to clear source profile and network target references explicitly during edits.
- Disable inline target host and port inputs when a network target is selected and validate target ports when using manual targets.
- Add runtime route tests covering removal of a network target reference while keeping the edited inline target port.
## 2026-04-15 - 13.19.0 - feat(routes,email)
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers
- Persist seeded DNS-over-HTTPS routes with stable system keys and hydrate socket handlers at runtime instead of treating them as runtime-only routes
- Restrict system-managed routes to toggle-only operations across the route manager, Ops API, and web UI while returning explicit mutation errors
- Add a shared email DNS record builder and cover email queue operations and handler behavior with new tests
## 2026-04-14 - 13.18.0 - feat(email)
add persistent smartmta storage and runtime-managed email domain syncing
- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence
- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart
- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation
## 2026-04-14 - 13.17.9 - fix(monitoring)
align domain activity metrics with id-keyed route data
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
- Add a regression test covering domain activity aggregation for routes identified only by id.
- Update the network activity UI to show formatted total connection counts in the active connections card.
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
## 2026-04-14 - 13.17.8 - fix(opsserver)
align certificate status handling with the updated smartproxy response format
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
- bump @push.rocks/smartproxy to ^27.7.3
- enable verbose output for the test script
## 2026-04-14 - 13.17.7 - fix(repo)
no changes to commit
## 2026-04-14 - 13.17.6 - fix(dns,routes) ## 2026-04-14 - 13.17.6 - fix(dns,routes)
keep DoH socket-handler routes runtime-only and prune stale persisted entries keep DoH socket-handler routes runtime-only and prune stale persisted entries

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "13.17.6", "version": "13.20.2",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -12,7 +12,7 @@
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "(tstest test/ --logfile --timeout 60)", "test": "(tstest test/ --verbose --logfile --timeout 60)",
"start": "(node ./cli.js)", "start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)", "startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)", "build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
@@ -50,11 +50,11 @@
"@push.rocks/smartlog": "^3.2.2", "@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3", "@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.2.0", "@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.1", "@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.6.0", "@push.rocks/smartnetwork": "^4.6.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.7.0", "@push.rocks/smartproxy": "^27.7.4",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",

58
pnpm-lock.yaml generated
View File

@@ -69,8 +69,8 @@ importers:
specifier: 1.2.0 specifier: 1.2.0
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7)) version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
'@push.rocks/smartmta': '@push.rocks/smartmta':
specifier: ^5.3.1 specifier: ^5.3.3
version: 5.3.1 version: 5.3.3
'@push.rocks/smartnetwork': '@push.rocks/smartnetwork':
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.6.0 version: 4.6.0
@@ -81,8 +81,8 @@ importers:
specifier: ^4.2.3 specifier: ^4.2.3
version: 4.2.3 version: 4.2.3
'@push.rocks/smartproxy': '@push.rocks/smartproxy':
specifier: ^27.7.0 specifier: ^27.7.4
version: 27.7.0 version: 27.7.4
'@push.rocks/smartradius': '@push.rocks/smartradius':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@@ -147,6 +147,9 @@ importers:
'@types/node': '@types/node':
specifier: ^25.6.0 specifier: ^25.6.0
version: 25.6.0 version: 25.6.0
typescript:
specifier: ^6.0.2
version: 6.0.2
packages: packages:
@@ -1248,8 +1251,8 @@ packages:
'@push.rocks/smartmongo@5.1.1': '@push.rocks/smartmongo@5.1.1':
resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==} resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==}
'@push.rocks/smartmta@5.3.1': '@push.rocks/smartmta@5.3.3':
resolution: {integrity: sha512-cEuXO56i/zL9eZS79eAesEW16ikdBJKLlEv9pLKkt2cmaHBWADGHjeOzJmsszQ9CSFcuhd41aHYVGMZXVvsG2g==} resolution: {integrity: sha512-QxNob2yosDOhHMMjfUiQHfx8z+/UQQUdZY4ECATg3/xAMwnychR41IEVp6h7Qz3RjoJqS3NjRBThm9/jT02Gxg==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [x64, arm64] cpu: [x64, arm64]
os: [darwin, linux, win32] os: [darwin, linux, win32]
@@ -1284,8 +1287,8 @@ packages:
'@push.rocks/smartpromise@4.2.3': '@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@27.7.0': '@push.rocks/smartproxy@27.7.4':
resolution: {integrity: sha512-0u8HF5ocQ2xmfCN1FWyulGTddZ4ZkWaip1j0alT8Bc/LdIYerjKtNJCU4N2wMk/Zz0Wl5UQOmBm4qIWmgRiEcg==} resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==}
'@push.rocks/smartpuppeteer@2.0.5': '@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -3020,6 +3023,9 @@ packages:
libmime@5.3.7: libmime@5.3.7:
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==} resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
libmime@5.3.8:
resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==}
libqp@2.1.1: libqp@2.1.1:
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
@@ -3082,6 +3088,9 @@ packages:
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.3.5: lru-cache@11.3.5:
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -3093,8 +3102,8 @@ packages:
lucide@1.8.0: lucide@1.8.0:
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==} resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
mailparser@3.9.6: mailparser@3.9.8:
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==} resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==}
make-dir@3.1.0: make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
@@ -3431,8 +3440,8 @@ packages:
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
engines: {node: '>= 6.13.0'} engines: {node: '>= 6.13.0'}
nodemailer@8.0.4: nodemailer@8.0.5:
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==} resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
normalize-newline@4.1.0: normalize-newline@4.1.0:
@@ -6405,7 +6414,7 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@push.rocks/smartmta@5.3.1': '@push.rocks/smartmta@5.3.3':
dependencies: dependencies:
'@push.rocks/smartfile': 13.1.2 '@push.rocks/smartfile': 13.1.2
'@push.rocks/smartfs': 1.5.0 '@push.rocks/smartfs': 1.5.0
@@ -6414,8 +6423,8 @@ snapshots:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2 '@push.rocks/smartrust': 1.3.2
'@tsclass/tsclass': 9.5.0 '@tsclass/tsclass': 9.5.0
lru-cache: 11.3.5 lru-cache: 10.4.3
mailparser: 3.9.6 mailparser: 3.9.8
uuid: 13.0.0 uuid: 13.0.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6506,7 +6515,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {} '@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.7.0': '@push.rocks/smartproxy@27.7.4':
dependencies: dependencies:
'@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
@@ -8588,6 +8597,13 @@ snapshots:
libbase64: 1.3.0 libbase64: 1.3.0
libqp: 2.1.1 libqp: 2.1.1
libmime@5.3.8:
dependencies:
encoding-japanese: 2.2.0
iconv-lite: 0.7.2
libbase64: 1.3.0
libqp: 2.1.1
libqp@2.1.1: {} libqp@2.1.1: {}
lightweight-charts@5.1.0: lightweight-charts@5.1.0:
@@ -8644,22 +8660,24 @@ snapshots:
lowercase-keys@3.0.0: {} lowercase-keys@3.0.0: {}
lru-cache@10.4.3: {}
lru-cache@11.3.5: {} lru-cache@11.3.5: {}
lru-cache@7.18.3: {} lru-cache@7.18.3: {}
lucide@1.8.0: {} lucide@1.8.0: {}
mailparser@3.9.6: mailparser@3.9.8:
dependencies: dependencies:
'@zone-eu/mailsplit': 5.4.8 '@zone-eu/mailsplit': 5.4.8
encoding-japanese: 2.2.0 encoding-japanese: 2.2.0
he: 1.2.0 he: 1.2.0
html-to-text: 9.0.5 html-to-text: 9.0.5
iconv-lite: 0.7.2 iconv-lite: 0.7.2
libmime: 5.3.7 libmime: 5.3.8
linkify-it: 5.0.0 linkify-it: 5.0.0
nodemailer: 8.0.4 nodemailer: 8.0.5
punycode.js: 2.3.1 punycode.js: 2.3.1
tlds: 1.261.0 tlds: 1.261.0
@@ -9164,7 +9182,7 @@ snapshots:
node-forge@1.4.0: {} node-forge@1.4.0: {}
nodemailer@8.0.4: {} nodemailer@8.0.5: {}
normalize-newline@4.1.0: normalize-newline@4.1.0:
dependencies: dependencies:

1763
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 // Verify unified email server was initialized
expect(router.emailServer).toBeTruthy(); expect(router.emailServer).toBeTruthy();
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
// Stop the router // Stop the router
await router.stop(); await router.stop();

View File

@@ -1,6 +1,6 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js'; import { DcRouter } from '../ts/classes.dcrouter.js';
import { RouteConfigManager } from '../ts/config/index.js'; import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js'; import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DnsManager } from '../ts/dns/manager.dns.js'; import { DnsManager } from '../ts/dns/manager.dns.js';
import { logger } from '../ts/logger.js'; import { logger } from '../ts/logger.js';
@@ -40,7 +40,7 @@ const clearTestState = async () => {
} }
}; };
tap.test('RouteConfigManager applies runtime DoH routes without persisting them', async () => { tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
await testDbPromise; await testDbPromise;
await clearTestState(); await clearTestState();
@@ -64,15 +64,24 @@ tap.test('RouteConfigManager applies runtime DoH routes without persisting them'
undefined, undefined,
undefined, undefined,
undefined, undefined,
() => (dcRouter as any).generateDnsRoutes(), undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
); );
await routeManager.initialize([], [], []); await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
await routeManager.applyRoutes();
const persistedRoutes = await RouteDoc.findAll(); const persistedRoutes = await RouteDoc.findAll();
expect(persistedRoutes.length).toEqual(0); expect(persistedRoutes.length).toEqual(2);
expect(appliedRoutes.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) { for (const routeSet of appliedRoutes) {
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query'); const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
@@ -85,10 +94,17 @@ tap.test('RouteConfigManager applies runtime DoH routes without persisting them'
} }
}); });
tap.test('RouteConfigManager removes stale persisted DoH socket-handler routes on startup', async () => { tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
await testDbPromise; await testDbPromise;
await clearTestState(); 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(); const staleDnsQueryRoute = new RouteDoc();
staleDnsQueryRoute.id = 'stale-doh-query'; staleDnsQueryRoute.id = 'stale-doh-query';
staleDnsQueryRoute.route = { staleDnsQueryRoute.route = {
@@ -109,46 +125,88 @@ tap.test('RouteConfigManager removes stale persisted DoH socket-handler routes o
staleDnsQueryRoute.origin = 'dns'; staleDnsQueryRoute.origin = 'dns';
await staleDnsQueryRoute.save(); await staleDnsQueryRoute.save();
const staleResolveRoute = new RouteDoc(); const appliedRoutes: any[][] = [];
staleResolveRoute.id = 'stale-doh-resolve'; const smartProxy = {
staleResolveRoute.route = { updateRoutes: async (routes: any[]) => {
name: 'dns-over-https-resolve', appliedRoutes.push(routes);
match: {
ports: [443],
domains: ['ns1.example.com'],
path: '/resolve',
}, },
action: {
type: 'socket-handler' as any,
} as any,
}; };
staleResolveRoute.enabled = true;
staleResolveRoute.createdAt = Date.now();
staleResolveRoute.updatedAt = Date.now();
staleResolveRoute.createdBy = 'test';
staleResolveRoute.origin = 'dns';
await staleResolveRoute.save();
const validRoute = new RouteDoc(); const routeManager = new RouteConfigManager(
validRoute.id = 'valid-forward-route'; () => smartProxy as any,
validRoute.route = { undefined,
name: 'valid-forward-route', undefined,
match: { undefined,
ports: [443], undefined,
domains: ['app.example.com'], 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;
}, },
action: { };
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }], const routeManager = new RouteConfigManager(() => smartProxy as any);
tls: { mode: 'terminate' as const }, await routeManager.initialize([
}, {
} as any; name: 'system-config-route',
validRoute.enabled = true; match: {
validRoute.createdAt = Date.now(); ports: [443],
validRoute.updatedAt = Date.now(); domains: ['app.example.com'],
validRoute.createdBy = 'test'; },
validRoute.origin = 'api'; action: {
await validRoute.save(); type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
tls: { mode: 'terminate' as const },
},
} as any,
], [], []);
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
expect(systemRoute).toBeDefined();
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
route: { name: 'renamed-system-route' } as any,
});
expect(updateResult.success).toEqual(false);
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
expect(deleteResult.success).toEqual(false);
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
expect(toggleResult.success).toEqual(true);
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
});
tap.test('RouteConfigManager clears a network target ref and keeps the edited inline target port', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = []; const appliedRoutes: any[][] = [];
const smartProxy = { const smartProxy = {
@@ -157,19 +215,117 @@ tap.test('RouteConfigManager removes stale persisted DoH socket-handler routes o
}, },
}; };
const routeManager = new RouteConfigManager(() => smartProxy as any); const resolver = new ReferenceResolver();
(resolver as any).targets.set('target-1', {
id: 'target-1',
name: 'SSH TARGET',
host: '10.0.0.5',
port: 443,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'test',
});
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
resolver,
);
await routeManager.initialize([], [], []); await routeManager.initialize([], [], []);
expect((await RouteDoc.findByName('dns-over-https-dns-query'))).toEqual(null); const routeId = await routeManager.createRoute(
expect((await RouteDoc.findByName('dns-over-https-resolve'))).toEqual(null); {
name: 'ssh-route',
match: { ports: [22] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 22 }],
},
} as any,
'test-user',
true,
{ networkTargetRef: 'target-1' },
);
const remainingRoutes = await RouteDoc.findAll(); expect((await RouteDoc.findById(routeId))?.route.action.targets?.[0].port).toEqual(443);
expect(remainingRoutes.length).toEqual(1); expect((await RouteDoc.findById(routeId))?.metadata?.networkTargetRef).toEqual('target-1');
expect(remainingRoutes[0].route.name).toEqual('valid-forward-route');
expect(appliedRoutes.length).toEqual(1); const updateResult = await routeManager.updateRoute(routeId, {
expect(appliedRoutes[0].length).toEqual(1); route: {
expect(appliedRoutes[0][0].name).toEqual('valid-forward-route'); action: {
targets: [{ host: '127.0.0.1', port: 29424 }],
},
} as any,
metadata: {
networkTargetRef: '',
networkTargetName: '',
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.action.targets?.[0].host).toEqual('127.0.0.1');
expect(storedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(storedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(storedRoute?.metadata?.networkTargetName).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(mergedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(mergedRoute?.metadata?.networkTargetName).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].action.targets[0].port).toEqual(29424);
});
tap.test('RouteConfigManager clears remote ingress config when route patch sets it to null', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
);
await routeManager.initialize([], [], []);
const routeId = await routeManager.createRoute(
{
name: 'remote-ingress-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
remoteIngress: {
enabled: true,
edgeFilter: ['edge-a', 'blue'],
},
} as any,
'test-user',
);
const updateResult = await routeManager.updateRoute(routeId, {
route: {
remoteIngress: null,
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.remoteIngress).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.remoteIngress).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
}); });
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => { tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {

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

@@ -0,0 +1,110 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
(manager as any).forwardingModeOverride = undefined;
(manager as any).vpnServer = { running: true };
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(true);
expect(stopCalls).toEqual(1);
expect(startCalls).toEqual(1);
expect((manager as any).resolvedForwardingMode).toEqual('socket');
});
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
const manager = new VpnManager({ forwardingMode: 'hybrid' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(false);
expect(stopCalls).toEqual(0);
expect(startCalls).toEqual(0);
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
});
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
const dcRouter = new DcRouter({
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
vpnConfig: { enabled: false },
});
let stopCalls = 0;
let setupCalls = 0;
let applyCalls = 0;
const resolverValues: Array<unknown> = [];
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
(dcRouter as any).routeConfigManager = {
setVpnClientIpsResolver: (resolver: unknown) => {
resolverValues.push(resolver);
},
applyRoutes: async () => {
applyCalls++;
},
};
(dcRouter as any).setupVpnServer = async () => {
setupCalls++;
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
};
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
expect(stopCalls).toEqual(1);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(0);
expect(typeof resolverValues.at(-1)).toEqual('function');
await dcRouter.updateVpnConfig({ enabled: false });
expect(stopCalls).toEqual(2);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(1);
expect(resolverValues.at(-1)).toBeUndefined();
expect(dcRouter.vpnManager).toBeUndefined();
});
export default tap.start()

View File

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

View File

@@ -9,6 +9,7 @@ import {
type IUnifiedEmailServerOptions, type IUnifiedEmailServerOptions,
type IEmailRoute, type IEmailRoute,
type IEmailDomainConfig, type IEmailDomainConfig,
type IStorageManagerLike,
} from '@push.rocks/smartmta'; } from '@push.rocks/smartmta';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { StorageBackedCertManager } from './classes.storage-cert-manager.js'; import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
@@ -25,11 +26,13 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js'; import { DnsManager } from './dns/manager.dns.js';
import { AcmeConfigManager } from './acme/manager.acme-config.js'; import { AcmeConfigManager } from './acme/manager.acme-config.js';
import { EmailDomainManager } from './email/classes.email-domain.manager.js'; import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
import type { IRoute } from '../ts_interfaces/data/route-management.js';
export interface IDcRouterOptions { export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -248,15 +251,13 @@ export class DcRouter {
public radiusServer?: RadiusServer; public radiusServer?: RadiusServer;
public opsServer!: OpsServer; public opsServer!: OpsServer;
public metricsManager?: MetricsManager; public metricsManager?: MetricsManager;
private emailEventSubscriptions: Array<{
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
eventName: string;
listener: (...args: any[]) => void;
}> = [];
// Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set() public storageManager: IStorageManagerLike;
public storageManager: any = {
get: async (_key: string) => null,
set: async (_key: string, _value: string) => {
// DKIM keys from smartmta — logged but not yet migrated to smartdata
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
},
};
// Unified database (smartdata + LocalSmartDb or external MongoDB) // Unified database (smartdata + LocalSmartDb or external MongoDB)
public dcRouterDb?: DcRouterDb; public dcRouterDb?: DcRouterDb;
@@ -315,7 +316,8 @@ export class DcRouter {
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = []; private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = []; private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Runtime-only DoH routes. These carry live socket handlers and must never be persisted. private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = []; private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access // Environment access
@@ -329,6 +331,10 @@ export class DcRouter {
// Resolve all data paths from baseDir // Resolve all data paths from baseDir
this.resolvedPaths = paths.resolvePaths(this.options.baseDir); this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
paths.ensureDataDirectories(this.resolvedPaths);
this.storageManager = new SmartMtaStorageManager(
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
);
// Initialize service manager and register all services // Initialize service manager and register all services
this.serviceManager = new plugins.taskbuffer.ServiceManager({ this.serviceManager = new plugins.taskbuffer.ServiceManager({
@@ -452,9 +458,13 @@ export class DcRouter {
.dependsOn('DcRouterDb') .dependsOn('DcRouterDb')
.withStart(async () => { .withStart(async () => {
this.emailDomainManager = new EmailDomainManager(this); this.emailDomainManager = new EmailDomainManager(this);
await this.emailDomainManager.start();
}) })
.withStop(async () => { .withStop(async () => {
this.emailDomainManager = undefined; if (this.emailDomainManager) {
await this.emailDomainManager.stop();
this.emailDomainManager = undefined;
}
}), }),
); );
} }
@@ -556,20 +566,7 @@ export class DcRouter {
this.routeConfigManager = new RouteConfigManager( this.routeConfigManager = new RouteConfigManager(
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
this.options.vpnConfig?.enabled this.createVpnRouteAllowListResolver(),
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
if (!this.vpnManager || !this.targetProfileManager) {
// VPN not ready yet — deny all until re-apply after VPN starts
return [];
}
return this.targetProfileManager.getMatchingClientIps(
route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
);
}
: undefined,
this.referenceResolver, this.referenceResolver,
// Sync routes to RemoteIngressManager whenever routes change, // Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary // then push updated derived ports to the Rust hub binary
@@ -581,13 +578,15 @@ export class DcRouter {
this.tunnelManager.syncAllowedEdges(); this.tunnelManager.syncAllowedEdges();
} }
}, },
() => this.runtimeDnsRoutes, undefined,
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
); );
this.apiTokenManager = new ApiTokenManager(); this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize(); await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize( await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
); );
await this.targetProfileManager.normalizeAllRouteRefs(); await this.targetProfileManager.normalizeAllRouteRefs();
@@ -610,19 +609,20 @@ export class DcRouter {
// Email Server: optional, depends on SmartProxy // Email Server: optional, depends on SmartProxy
if (this.options.emailConfig) { if (this.options.emailConfig) {
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
if (this.options.dbConfig?.enabled !== false) {
emailServiceDeps.push('EmailDomainManager');
}
this.serviceManager.addService( this.serviceManager.addService(
new plugins.taskbuffer.Service('EmailServer') new plugins.taskbuffer.Service('EmailServer')
.optional() .optional()
.dependsOn('SmartProxy') .dependsOn(...emailServiceDeps)
.withStart(async () => { .withStart(async () => {
await this.setupUnifiedEmailHandling(); await this.setupUnifiedEmailHandling();
}) })
.withStop(async () => { .withStop(async () => {
if (this.emailServer) { if (this.emailServer) {
if ((this.emailServer as any).deliverySystem) { this.clearEmailEventSubscriptions();
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
await this.emailServer.stop(); await this.emailServer.stop();
this.emailServer = undefined; this.emailServer = undefined;
} }
@@ -636,7 +636,7 @@ export class DcRouter {
this.serviceManager.addService( this.serviceManager.addService(
new plugins.taskbuffer.Service('DnsServer') new plugins.taskbuffer.Service('DnsServer')
.optional() .optional()
.dependsOn('SmartProxy') .dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
.withStart(async () => { .withStart(async () => {
await this.setupDnsWithSocketHandler(); await this.setupDnsWithSocketHandler();
}) })
@@ -904,10 +904,12 @@ export class DcRouter {
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) }); logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
} }
this.seedDnsRoutes = [];
this.runtimeDnsRoutes = []; this.runtimeDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
this.runtimeDnsRoutes = this.generateDnsRoutes(); this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false });
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) }); this.runtimeDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: true });
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
} }
// Combined routes for SmartProxy bootstrap (before DB routes are loaded) // Combined routes for SmartProxy bootstrap (before DB routes are loaded)
@@ -1330,19 +1332,20 @@ export class DcRouter {
/** /**
* Generate SmartProxy routes for DNS configuration * 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) { if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
return []; return [];
} }
const includeSocketHandler = options?.includeSocketHandler !== false;
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = []; const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Create routes for DNS-over-HTTPS paths // Create routes for DNS-over-HTTPS paths
const dohPaths = ['/dns-query', '/resolve']; const dohPaths = ['/dns-query', '/resolve'];
// Use the first nameserver domain for DoH routes // Use the first nameserver domain for DoH routes
const primaryNameserver = this.options.dnsNsDomains[0]; const primaryNameserver = this.options.dnsNsDomains[0];
for (const path of dohPaths) { for (const path of dohPaths) {
const dohRoute: plugins.smartproxy.IRouteConfig = { const dohRoute: plugins.smartproxy.IRouteConfig = {
name: `dns-over-https-${path.replace('/', '')}`, name: `dns-over-https-${path.replace('/', '')}`,
@@ -1351,18 +1354,42 @@ export class DcRouter {
domains: [primaryNameserver], domains: [primaryNameserver],
path: path path: path
}, },
action: { action: includeSocketHandler
type: 'socket-handler' as any, ? {
socketHandler: this.createDnsSocketHandler() type: 'socket-handler' as any,
} as any socketHandler: this.createDnsSocketHandler()
} as any
: {
type: 'socket-handler' as any,
} as any
}; };
dnsRoutes.push(dohRoute); dnsRoutes.push(dohRoute);
} }
return dnsRoutes; 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) * Check if a domain matches a pattern (including wildcard support)
* @param domain The domain to check * @param domain The domain to check
@@ -1511,40 +1538,74 @@ export class DcRouter {
...this.options.emailConfig, ...this.options.emailConfig,
domains: transformedDomains, domains: transformedDomains,
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000), ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
hostname: 'localhost' // Listen on localhost for SmartProxy forwarding persistRoutes: this.options.emailConfig.persistRoutes ?? false,
queue: {
storageType: 'disk',
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
...this.options.emailConfig.queue,
},
}; };
// Create unified email server // Create unified email server
this.emailServer = new UnifiedEmailServer(this, emailConfig); this.emailServer = new UnifiedEmailServer(this, emailConfig);
this.clearEmailEventSubscriptions();
// Set up error handling // Set up error handling
this.emailServer.on('error', (err: Error) => { this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
logger.log('error', `UnifiedEmailServer error: ${err.message}`); logger.log('error', `UnifiedEmailServer error: ${err.message}`);
}); });
// Start the server // Start the server
await this.emailServer.start(); await this.emailServer.start();
// Wire delivery events to MetricsManager and logger // Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
if (this.metricsManager && this.emailServer.deliverySystem) {
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
this.metricsManager!.trackEmailReceived(item?.from);
logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' });
});
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
this.metricsManager!.trackEmailSent(item?.to);
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
});
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
this.metricsManager!.trackEmailFailed(item?.to, error?.message);
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
});
}
if (this.metricsManager && this.emailServer) { if (this.metricsManager && this.emailServer) {
this.emailServer.on('bounceProcessed', () => { const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
const emailLike = item?.processingResult;
const from = emailLike?.from || emailLike?.email?.from || '';
const recipients = Array.isArray(emailLike?.to)
? emailLike.to
: Array.isArray(emailLike?.email?.to)
? emailLike.email.to
: [];
return {
from,
recipients: recipients.filter(Boolean),
};
};
const updateQueueSize = () => {
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
};
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailReceived(envelope.from);
updateQueueSize();
logger.log('info', `Email queued: ${envelope.from}${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
updateQueueSize();
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
updateQueueSize();
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
updateQueueSize();
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
updateQueueSize();
});
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
this.metricsManager!.trackEmailBounced(); this.metricsManager!.trackEmailBounced();
logger.log('warn', 'Email bounce processed', { zone: 'email' }); logger.log('warn', 'Email bounce processed', { zone: 'email' });
}); });
updateQueueSize();
} }
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`); logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
@@ -1574,11 +1635,7 @@ export class DcRouter {
try { try {
// Stop the unified email server which contains all components // Stop the unified email server which contains all components
if (this.emailServer) { if (this.emailServer) {
// Remove listeners before stopping to prevent leaks on config update cycles this.clearEmailEventSubscriptions();
if ((this.emailServer as any).deliverySystem) {
(this.emailServer as any).deliverySystem.removeAllListeners();
}
this.emailServer.removeAllListeners();
await this.emailServer.stop(); await this.emailServer.stop();
logger.log('info', 'Unified email server stopped'); logger.log('info', 'Unified email server stopped');
this.emailServer = undefined; this.emailServer = undefined;
@@ -1783,14 +1840,14 @@ export class DcRouter {
// Generate and register authoritative records // Generate and register authoritative records
const authoritativeRecords = await this.generateAuthoritativeRecords(); const authoritativeRecords = await this.generateAuthoritativeRecords();
// Generate email DNS records // Generate email DNS records
const emailDnsRecords = await this.generateEmailDnsRecords(); const emailDnsRecords = await this.generateEmailDnsRecords();
// Initialize DKIM for all email domains // Ensure DKIM keys exist for internal-dns domains before generating records.
await this.initializeDkimForEmailDomains(); await this.initializeDkimForEmailDomains();
// Load DKIM records from JSON files (they should now exist) // Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
const dkimRecords = await this.loadDkimRecords(); const dkimRecords = await this.loadDkimRecords();
// Combine all records: authoritative, email, DKIM, and user-defined // Combine all records: authoritative, email, DKIM, and user-defined
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords]; const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
@@ -1901,37 +1958,20 @@ export class DcRouter {
for (const domainConfig of internalDnsDomains) { for (const domainConfig of internalDnsDomains) {
const domain = domainConfig.domain; const domain = domainConfig.domain;
const ttl = domainConfig.dns?.internal?.ttl || 3600; const ttl = domainConfig.dns?.internal?.ttl || 3600;
const mxPriority = domainConfig.dns?.internal?.mxPriority || 10; const requiredRecords = buildEmailDnsRecords({
domain,
// MX record - points to the domain itself for email handling hostname: this.options.emailConfig.hostname,
records.push({ mxPriority: domainConfig.dns?.internal?.mxPriority,
name: domain, }).filter((record) => !record.name.includes('._domainkey.'));
type: 'MX',
value: `${mxPriority} ${domain}`, for (const record of requiredRecords) {
ttl records.push({
}); name: record.name,
type: record.type,
// SPF record - using sensible defaults value: record.value,
const spfRecord = 'v=spf1 a mx ~all'; ttl,
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
} }
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`); logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
@@ -1939,54 +1979,30 @@ export class DcRouter {
} }
/** /**
* Load DKIM records from JSON files * Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
* Reads all *.dkimrecord.json files from the DNS records directory
*/ */
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> { private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
const records: Array<{name: string; type: string; value: string; ttl?: number}> = []; const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
try { return records;
// Ensure paths are imported }
const dnsDir = this.resolvedPaths.dnsRecordsDir;
for (const domainConfig of this.options.emailConfig.domains) {
// Check if directory exists if (domainConfig.dnsMode !== 'internal-dns') {
if (!plugins.fs.existsSync(dnsDir)) { continue;
logger.log('debug', 'No DNS records directory found, skipping DKIM record loading');
return records;
} }
const selector = domainConfig.dkim?.selector || 'default';
// Read all files in the directory try {
const files = plugins.fs.readdirSync(dnsDir); const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json')); records.push({
name: dkimRecord.name,
logger.log('info', `Found ${dkimFiles.length} DKIM record files`); type: 'TXT',
value: dkimRecord.value,
// Load each DKIM record ttl: domainConfig.dns?.internal?.ttl || 3600,
for (const file of dkimFiles) { });
try { } catch (error: unknown) {
const filePath = plugins.path.join(dnsDir, file); logger.log('error', `Failed to generate DKIM record for ${domainConfig.domain}: ${(error as Error).message}`);
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}`);
}
} }
} catch (error: unknown) {
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
} }
return records; return records;
@@ -2013,12 +2029,17 @@ export class DcRouter {
// Ensure necessary directories exist // Ensure necessary directories exist
paths.ensureDataDirectories(this.resolvedPaths); paths.ensureDataDirectories(this.resolvedPaths);
// Generate DKIM keys for each email domain // Generate DKIM keys for each internal-dns email domain using the configured selector.
for (const domainConfig of this.options.emailConfig.domains) { for (const domainConfig of this.options.emailConfig.domains) {
if (domainConfig.dnsMode !== 'internal-dns') {
continue;
}
try { try {
// Generate DKIM keys for all domains, regardless of DNS mode await dkimCreator.handleDKIMKeysForSelector(
// This ensures keys are ready even if DNS mode changes later domainConfig.domain,
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain); domainConfig.dkim?.selector || 'default',
domainConfig.dkim?.keySize || 2048,
);
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`); logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
} catch (error: unknown) { } catch (error: unknown) {
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`); logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
@@ -2148,6 +2169,25 @@ export class DcRouter {
} }
} }
} }
private addEmailEventSubscription(
emitter: {
on(eventName: string, listener: (...args: any[]) => void): void;
off(eventName: string, listener: (...args: any[]) => void): void;
},
eventName: string,
listener: (...args: any[]) => void,
): void {
emitter.on(eventName, listener);
this.emailEventSubscriptions.push({ emitter, eventName, listener });
}
private clearEmailEventSubscriptions(): void {
for (const subscription of this.emailEventSubscriptions) {
subscription.emitter.off(subscription.eventName, subscription.listener);
}
this.emailEventSubscriptions = [];
}
/** /**
* Detect the server's public IP address * Detect the server's public IP address
@@ -2240,6 +2280,32 @@ export class DcRouter {
/** /**
* Set up VPN server for VPN-based route access control. * Set up VPN server for VPN-based route access control.
*/ */
private createVpnRouteAllowListResolver(): ((
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string,
) => TIpAllowEntry[]) | undefined {
if (!this.options.vpnConfig?.enabled) {
return undefined;
}
return (
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string,
) => {
if (!this.vpnManager || !this.targetProfileManager) {
// VPN not ready yet — deny all until re-apply after VPN starts.
return [];
}
return this.targetProfileManager.getMatchingClientIps(
route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
);
};
}
private async setupVpnServer(): Promise<void> { private async setupVpnServer(): Promise<void> {
if (!this.options.vpnConfig?.enabled) { if (!this.options.vpnConfig?.enabled) {
return; return;
@@ -2389,6 +2455,29 @@ export class DcRouter {
logger.log('info', 'RADIUS configuration updated'); logger.log('info', 'RADIUS configuration updated');
} }
/**
* Update VPN configuration at runtime.
*/
public async updateVpnConfig(config: IDcRouterOptions['vpnConfig']): Promise<void> {
if (this.vpnManager) {
await this.vpnManager.stop();
this.vpnManager = undefined;
}
this.options.vpnConfig = config;
this.vpnDomainIpCache.clear();
this.warnedWildcardVpnDomains.clear();
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver());
if (this.options.vpnConfig?.enabled) {
await this.setupVpnServer();
} else {
await this.routeConfigManager?.applyRoutes();
}
logger.log('info', 'VPN configuration updated');
}
} }
// Re-export email server types for convenience // Re-export email server types for convenience

View File

@@ -14,6 +14,11 @@ import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */ /** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] }; 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 * Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners. * never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
@@ -56,6 +61,7 @@ export class RouteConfigManager {
private referenceResolver?: ReferenceResolver, private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[], private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
) {} ) {}
/** Expose routes map for reference resolution lookups. */ /** Expose routes map for reference resolution lookups. */
@@ -63,6 +69,16 @@ export class RouteConfigManager {
return this.routes; return this.routes;
} }
public getRoute(id: string): IRoute | undefined {
return this.routes.get(id);
}
public setVpnClientIpsResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
): void {
this.getVpnClientIpsForRoute = resolver;
}
/** /**
* Load persisted routes, seed serializable config/email/dns routes, * Load persisted routes, seed serializable config/email/dns routes,
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy. * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
@@ -94,6 +110,7 @@ export class RouteConfigManager {
id: route.id, id: route.id,
enabled: route.enabled, enabled: route.enabled,
origin: route.origin, origin: route.origin,
systemKey: route.systemKey,
createdAt: route.createdAt, createdAt: route.createdAt,
updatedAt: route.updatedAt, updatedAt: route.updatedAt,
metadata: route.metadata, metadata: route.metadata,
@@ -122,11 +139,11 @@ export class RouteConfigManager {
} }
// Resolve references if metadata has refs and resolver is available // Resolve references if metadata has refs and resolver is available
let resolvedMetadata = metadata; let resolvedMetadata = this.normalizeRouteMetadata(metadata);
if (metadata && this.referenceResolver) { if (resolvedMetadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(route, metadata); const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
route = resolved.route; route = resolved.route;
resolvedMetadata = resolved.metadata; resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
} }
const stored: IRoute = { const stored: IRoute = {
@@ -153,9 +170,21 @@ export class RouteConfigManager {
enabled?: boolean; enabled?: boolean;
metadata?: Partial<IRouteMetadata>; metadata?: Partial<IRouteMetadata>;
}, },
): Promise<boolean> { ): Promise<IRouteMutationResult> {
const stored = this.routes.get(id); 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) { if (patch.route) {
const mergedAction = patch.route.action const mergedAction = patch.route.action
@@ -169,39 +198,61 @@ export class RouteConfigManager {
} }
} }
} }
stored.route = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig; const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
// Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null)
for (const [key, val] of Object.entries(patch.route)) {
if (val === null && key !== 'action' && key !== 'match') {
delete (mergedRoute as any)[key];
}
}
stored.route = mergedRoute;
} }
if (patch.enabled !== undefined) { if (patch.enabled !== undefined) {
stored.enabled = patch.enabled; stored.enabled = patch.enabled;
} }
if (patch.metadata !== undefined) { if (patch.metadata !== undefined) {
stored.metadata = { ...stored.metadata, ...patch.metadata }; stored.metadata = this.normalizeRouteMetadata({
...stored.metadata,
...patch.metadata,
});
} }
// Re-resolve if metadata refs exist and resolver is available // Re-resolve if metadata refs exist and resolver is available
if (stored.metadata && this.referenceResolver) { if (stored.metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route; stored.route = resolved.route;
stored.metadata = resolved.metadata; stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
} }
stored.updatedAt = Date.now(); stored.updatedAt = Date.now();
await this.persistRoute(stored); await this.persistRoute(stored);
await this.applyRoutes(); await this.applyRoutes();
return true; return { success: true };
} }
public async deleteRoute(id: string): Promise<boolean> { public async deleteRoute(id: string): Promise<IRouteMutationResult> {
if (!this.routes.has(id)) return false; 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); this.routes.delete(id);
const doc = await RouteDoc.findById(id); const doc = await RouteDoc.findById(id);
if (doc) await doc.delete(); if (doc) await doc.delete();
await this.applyRoutes(); 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 }); return this.updateRoute(id, { enabled });
} }
@@ -217,29 +268,28 @@ export class RouteConfigManager {
seedRoutes: IDcRouterRouteConfig[], seedRoutes: IDcRouterRouteConfig[],
origin: 'config' | 'email' | 'dns', origin: 'config' | 'email' | 'dns',
): Promise<void> { ): Promise<void> {
if (seedRoutes.length === 0) return; const seedSystemKeys = new Set<string>();
const seedNames = new Set<string>(); const seedNames = new Set<string>();
let seeded = 0; let seeded = 0;
let updated = 0; let updated = 0;
for (const route of seedRoutes) { for (const route of seedRoutes) {
const name = route.name || ''; const name = route.name || '';
seedNames.add(name); if (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;
}
} }
const systemKey = this.buildSystemRouteKey(origin, route);
if (systemKey) {
seedSystemKeys.add(systemKey);
}
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
if (existingId) { if (existingId) {
// Update route config but preserve enabled state // Update route config but preserve enabled state
const existing = this.routes.get(existingId)!; const existing = this.routes.get(existingId)!;
existing.route = route; existing.route = route;
existing.systemKey = systemKey;
existing.updatedAt = Date.now(); existing.updatedAt = Date.now();
await this.persistRoute(existing); await this.persistRoute(existing);
updated++; updated++;
@@ -255,6 +305,7 @@ export class RouteConfigManager {
updatedAt: now, updatedAt: now,
createdBy: 'system', createdBy: 'system',
origin, origin,
systemKey,
}; };
this.routes.set(id, newRoute); this.routes.set(id, newRoute);
await this.persistRoute(newRoute); await this.persistRoute(newRoute);
@@ -265,7 +316,12 @@ export class RouteConfigManager {
// Delete stale routes: same origin but name not in current seed set // Delete stale routes: same origin but name not in current seed set
const staleIds: string[] = []; const staleIds: string[] = [];
for (const [id, r] of this.routes) { 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); staleIds.push(id);
} }
} }
@@ -284,9 +340,39 @@ export class RouteConfigManager {
// Private: persistence // 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> { private async loadRoutes(): Promise<void> {
const docs = await RouteDoc.findAll(); const docs = await RouteDoc.findAll();
let prunedRuntimeRoutes = 0;
for (const doc of docs) { for (const doc of docs) {
if (!doc.id) continue; if (!doc.id) continue;
@@ -299,27 +385,15 @@ export class RouteConfigManager {
updatedAt: doc.updatedAt, updatedAt: doc.updatedAt,
createdBy: doc.createdBy, createdBy: doc.createdBy,
origin: doc.origin || 'api', origin: doc.origin || 'api',
metadata: doc.metadata, systemKey: doc.systemKey,
metadata: this.normalizeRouteMetadata(doc.metadata),
}; };
if (this.isPersistedRuntimeRoute(storedRoute)) {
await doc.delete();
prunedRuntimeRoutes++;
logger.log(
'warn',
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
);
continue;
}
this.routes.set(doc.id, storedRoute); this.routes.set(doc.id, storedRoute);
} }
if (this.routes.size > 0) { if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.routes.size} route(s) from database`); logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
} }
if (prunedRuntimeRoutes > 0) {
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
}
} }
private async persistRoute(stored: IRoute): Promise<void> { private async persistRoute(stored: IRoute): Promise<void> {
@@ -330,6 +404,7 @@ export class RouteConfigManager {
existingDoc.updatedAt = stored.updatedAt; existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy; existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin; existingDoc.origin = stored.origin;
existingDoc.systemKey = stored.systemKey;
existingDoc.metadata = stored.metadata; existingDoc.metadata = stored.metadata;
await existingDoc.save(); await existingDoc.save();
} else { } else {
@@ -341,11 +416,52 @@ export class RouteConfigManager {
doc.updatedAt = stored.updatedAt; doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy; doc.createdBy = stored.createdBy;
doc.origin = stored.origin; doc.origin = stored.origin;
doc.systemKey = stored.systemKey;
doc.metadata = stored.metadata; doc.metadata = stored.metadata;
await doc.save(); await doc.save();
} }
} }
private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
if (!metadata) {
return undefined;
}
const normalizeString = (value?: string): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const normalized: IRouteMetadata = {
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
networkTargetRef: normalizeString(metadata.networkTargetRef),
sourceProfileName: normalizeString(metadata.sourceProfileName),
networkTargetName: normalizeString(metadata.networkTargetName),
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
? metadata.lastResolvedAt
: undefined,
};
if (!normalized.sourceProfileRef) {
normalized.sourceProfileName = undefined;
}
if (!normalized.networkTargetRef) {
normalized.networkTargetName = undefined;
}
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (Object.values(normalized).every((value) => value === undefined)) {
return undefined;
}
return normalized;
}
// ========================================================================= // =========================================================================
// Private: warnings // Private: warnings
// ========================================================================= // =========================================================================
@@ -388,7 +504,7 @@ export class RouteConfigManager {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route; stored.route = resolved.route;
stored.metadata = resolved.metadata; stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
stored.updatedAt = Date.now(); stored.updatedAt = Date.now();
await this.persistRoute(stored); await this.persistRoute(stored);
} }
@@ -411,7 +527,7 @@ export class RouteConfigManager {
// Add all enabled routes with HTTP/3 and VPN augmentation // Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.routes.values()) { for (const route of this.routes.values()) {
if (route.enabled) { if (route.enabled) {
enabledRoutes.push(this.prepareRouteForApply(route.route, route.id)); enabledRoutes.push(this.prepareStoredRouteForApply(route));
} }
} }
@@ -431,6 +547,11 @@ export class RouteConfigManager {
}); });
} }
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
}
private prepareRouteForApply( private prepareRouteForApply(
route: plugins.smartproxy.IRouteConfig, route: plugins.smartproxy.IRouteConfig,
routeId?: string, routeId?: string,
@@ -465,12 +586,4 @@ export class RouteConfigManager {
}, },
}; };
} }
private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
const routeName = storedRoute.route.name || '';
const actionType = storedRoute.route.action?.type;
return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
|| (storedRoute.origin === 'dns' && actionType === 'socket-handler');
}
} }

View File

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

View File

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

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.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[]>(); const routeDomains = new Map<string, string[]>();
if (this.dcRouter.smartProxy) { if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) { for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
if (!route.name || !route.match.domains) continue; const routeKey = route.name || route.id;
if (!routeKey || !route.match.domains) continue;
const domains = Array.isArray(route.match.domains) const domains = Array.isArray(route.match.domains)
? route.match.domains ? route.match.domains
: [route.match.domains]; : [route.match.domains];
if (domains.length > 0) { if (domains.length > 0) {
routeDomains.set(route.name, domains); routeDomains.set(routeKey, domains);
} }
} }
} }
@@ -753,23 +754,23 @@ export class MetricsManager {
if (entry.domain) allKnownDomains.add(entry.domain); if (entry.domain) allKnownDomains.add(entry.domain);
} }
// Build reverse map: concrete domain → route name(s) // Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>(); const domainToRoutes = new Map<string, string[]>();
for (const [routeName, domains] of routeDomains) { for (const [routeKey, domains] of routeDomains) {
for (const pattern of domains) { for (const pattern of domains) {
if (pattern.includes('*')) { if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$'); const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const knownDomain of allKnownDomains) { for (const knownDomain of allKnownDomains) {
if (regex.test(knownDomain)) { if (regex.test(knownDomain)) {
const existing = domainToRoutes.get(knownDomain); const existing = domainToRoutes.get(knownDomain);
if (existing) { existing.push(routeName); } if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(knownDomain, [routeName]); } else { domainToRoutes.set(knownDomain, [routeKey]); }
} }
} }
} else { } else {
const existing = domainToRoutes.get(pattern); const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeName); } if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(pattern, [routeName]); } else { domainToRoutes.set(pattern, [routeKey]); }
} }
} }
} }
@@ -777,10 +778,10 @@ export class MetricsManager {
// For each route, compute the total request count across all its resolved domains // For each route, compute the total request count across all its resolved domains
// so we can distribute throughput/connections proportionally // so we can distribute throughput/connections proportionally
const routeTotalRequests = new Map<string, number>(); const routeTotalRequests = new Map<string, number>();
for (const [domain, routeNames] of domainToRoutes) { for (const [domain, routeKeys] of domainToRoutes) {
const reqs = domainRequestTotals.get(domain) || 0; const reqs = domainRequestTotals.get(domain) || 0;
for (const routeName of routeNames) { for (const routeKey of routeKeys) {
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 0) + reqs); routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
} }
} }
@@ -793,16 +794,16 @@ export class MetricsManager {
requestCount: number; requestCount: number;
}>(); }>();
for (const [domain, routeNames] of domainToRoutes) { for (const [domain, routeKeys] of domainToRoutes) {
const domainReqs = domainRequestTotals.get(domain) || 0; const domainReqs = domainRequestTotals.get(domain) || 0;
let totalConns = 0; let totalConns = 0;
let totalIn = 0; let totalIn = 0;
let totalOut = 0; let totalOut = 0;
for (const routeName of routeNames) { for (const routeKey of routeKeys) {
const conns = connectionsByRoute.get(routeName) || 0; const conns = connectionsByRoute.get(routeKey) || 0;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 }; const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
const routeTotal = routeTotalRequests.get(routeName) || 0; const routeTotal = routeTotalRequests.get(routeKey) || 0;
const share = routeTotal > 0 ? domainReqs / routeTotal : 0; const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
totalConns += conns * share; totalConns += conns * share;
@@ -814,7 +815,7 @@ export class MetricsManager {
activeConnections: Math.round(totalConns), activeConnections: Math.round(totalConns),
bytesInPerSec: totalIn, bytesInPerSec: totalIn,
bytesOutPerSec: totalOut, bytesOutPerSec: totalOut,
routeCount: routeNames.length, routeCount: routeKeys.length,
requestCount: domainReqs, requestCount: domainReqs,
}); });
} }
@@ -990,4 +991,4 @@ export class MetricsManager {
return { queries }; return { queries };
} }
} }

View File

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

View File

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

View File

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

View File

@@ -530,13 +530,49 @@ export class StatsHandler {
nextRetry?: number; 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 { return {
pending: 0, pending: queueStats.status.pending,
active: 0, active: queueStats.status.processing,
failed: 0, failed: queueStats.status.failed,
retrying: 0, retrying: queueStats.status.deferred,
items: [], items,
}; };
} }
@@ -600,4 +636,4 @@ export class StatsHandler {
], ],
}; };
} }
} }

View File

@@ -1,8 +1,6 @@
# @serve.zone/dcrouter # @serve.zone/dcrouter
The core DcRouter package — a unified datacenter gateway orchestrator. 🚀 The `ts/` directory is the main dcrouter runtime package. It exposes the `DcRouter` orchestrator, `IDcRouterOptions`, `runCli()`, and the server-side exports that matter when you want to boot the full router stack from code.
This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -14,7 +12,19 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter pnpm add @serve.zone/dcrouter
``` ```
## Usage ## Core Exports
| Export | Purpose |
| --- | --- |
| `DcRouter` | Main orchestrator for proxying, DNS, email, VPN, RADIUS, remote ingress, DB, and OpsServer |
| `IDcRouterOptions` | Top-level configuration shape |
| `runCli()` | Bootstrap helper; uses OCI env-driven config when `DCROUTER_MODE=OCI_CONTAINER` |
| `UnifiedEmailServer` and smartmta types | Re-exported email server primitives |
| `RadiusServer` and related types | RADIUS server runtime exports |
| `RemoteIngressManager` and `TunnelManager` | Remote ingress orchestration exports |
| `IHttp3Config` | HTTP/3 configuration for qualifying HTTPS routes |
## Quick Start
```typescript ```typescript
import { DcRouter } from '@serve.zone/dcrouter'; import { DcRouter } from '@serve.zone/dcrouter';
@@ -23,120 +33,53 @@ const router = new DcRouter({
smartProxyConfig: { smartProxyConfig: {
routes: [ routes: [
{ {
name: 'web-app', name: 'local-app',
match: { domains: ['example.com'], ports: [443] }, match: {
domains: ['localhost'],
ports: [18080],
},
action: { action: {
type: 'forward', type: 'forward',
targets: [{ host: '192.168.1.10', port: 8080 }], targets: [{ host: '127.0.0.1', port: 3001 }],
tls: { mode: 'terminate', certificate: 'auto' } },
} },
}
], ],
acme: { email: 'admin@example.com', enabled: true, useProduction: true } },
} opsServerPort: 3000,
}); });
await router.start(); await router.start();
// OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
// Graceful shutdown
await router.stop();
``` ```
## Module Structure ## What `DcRouter` Manages
``` - SmartProxy for HTTP/HTTPS/TCP routes
ts/ - `UnifiedEmailServer` for SMTP ingress and delivery when `emailConfig` is present
├── index.ts # Main exports (DcRouter, re-exported smartmta types) - DB-backed managers for routes, API tokens, target profiles, domains, records, ACME config, and email domains when the DB is enabled
├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions - embedded authoritative DNS and DoH route generation from `dnsNsDomains` and `dnsScopes`
├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler - VPN, RADIUS, and remote ingress services when their config blocks are enabled
├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager - OpsServer and the dashboard, which start on every boot
├── logger.ts # Structured logging utility
├── paths.ts # Centralized data directory paths
├── plugins.ts # All dependency imports
├── cache/ # Cache database (smartdata + LocalTsmDb)
│ ├── classes.cachedb.ts # CacheDb singleton
│ ├── classes.cachecleaner.ts # TTL-based cleanup
│ └── documents/ # Cached document models
├── config/ # Configuration utilities
├── errors/ # Error classes and retry logic
├── http3/ # HTTP/3 (QUIC) route augmentation
│ ├── index.ts # Barrel export
│ └── http3-route-augmentation.ts # Pure utility: augmentRoutesWithHttp3(), IHttp3Config
├── monitoring/ # MetricsManager (SmartMetrics integration)
├── opsserver/ # OpsServer dashboard + API handlers
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
│ └── handlers/ # TypedRequest handlers by domain
│ ├── admin.handler.ts # Auth (login/logout/verify)
│ ├── stats.handler.ts # Statistics + health
│ ├── config.handler.ts # Configuration (read-only)
│ ├── logs.handler.ts # Log retrieval
│ ├── email.handler.ts # Email operations
│ ├── certificate.handler.ts # Certificate management
│ ├── radius.handler.ts # RADIUS management
│ ├── remoteingress.handler.ts # Remote ingress edge + token management
│ ├── route-management.handler.ts # Programmatic route CRUD
│ ├── api-token.handler.ts # API token management
│ └── security.handler.ts # Security metrics + connections
├── radius/ # RADIUS server integration
├── remoteingress/ # Remote ingress hub integration
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
│ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
├── security/ # Security utilities
├── sms/ # SMS integration
└── storage/ # StorageManager (filesystem/custom/memory)
```
## Exports ## Important Runtime Behavior
```typescript - The DB is enabled by default and uses an embedded local database when no external MongoDB URL is provided.
// Main class - System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js'; - API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
- Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
- `runCli()` is the supported code-level bootstrap entrypoint; the package does not expose a separate npm `bin` command.
// Re-exported from smartmta ## Use Another Module When...
export { UnifiedEmailServer } from '@push.rocks/smartmta';
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
// RADIUS | Need | Module |
export { RadiusServer, IRadiusServerConfig } from './radius/index.js'; | --- | --- |
| A higher-level client SDK for a running router | `@serve.zone/dcrouter-apiclient` or `@serve.zone/dcrouter/apiclient` |
// Remote Ingress | Raw TypedRequest request/data contracts | `@serve.zone/dcrouter-interfaces` or `@serve.zone/dcrouter/interfaces` |
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; | The standalone migration runner | `@serve.zone/dcrouter-migrations` |
| The browser dashboard module boundary | `@serve.zone/dcrouter-web` |
// HTTP/3
export type { IHttp3Config } from './http3/index.js';
```
## Key Classes
### `DcRouter`
The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
| Config Section | Service Started | Package |
|----------------|----------------|---------|
| `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
| `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
| `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
| `http3` | HTTP/3 route augmentation (enabled by default) | built-in |
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
### `RemoteIngressManager`
Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
### `TunnelManager`
Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
## License and Legal Information ## 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. 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. **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.
@@ -148,7 +91,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information ### Company Information
Task Venture Capital GmbH Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany 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. For any legal inquiries or further information, please contact us via email at hello@task.vc.

View File

@@ -112,14 +112,11 @@ export class VpnManager {
const subnet = this.getSubnet(); const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820; const wgListenPort = this.config.wgListenPort ?? 51820;
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
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)'); logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
} }
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode; const forwardingMode = desiredForwardingMode;
const isBridge = forwardingMode === 'bridge'; const isBridge = forwardingMode === 'bridge';
this.resolvedForwardingMode = forwardingMode; this.resolvedForwardingMode = forwardingMode;
this.forwardingModeOverride = undefined; this.forwardingModeOverride = undefined;
@@ -218,7 +215,7 @@ export class VpnManager {
throw new Error('VPN server not running'); throw new Error('VPN server not running');
} }
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true); await this.ensureForwardingModeForNextClient(opts.useHostIp === true);
const doc = new VpnClientDoc(); const doc = new VpnClientDoc();
doc.clientId = opts.clientId; doc.clientId = opts.clientId;
@@ -298,6 +295,7 @@ export class VpnManager {
if (doc) { if (doc) {
await doc.delete(); await doc.delete();
} }
await this.reconcileForwardingMode();
this.config.onClientChanged?.(); this.config.onClientChanged?.();
} }
@@ -368,8 +366,10 @@ export class VpnManager {
await this.persistClient(client); await this.persistClient(client);
if (this.vpnServer) { if (this.vpnServer) {
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true); const restarted = await this.reconcileForwardingMode();
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client)); if (!restarted) {
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
}
} }
this.config.onClientChanged?.(); this.config.onClientChanged?.();
@@ -563,6 +563,28 @@ export class VpnManager {
?? 'socket'; ?? 'socket';
} }
private hasHostIpClients(extraHostIpClient = false): boolean {
if (extraHostIpClient) {
return true;
}
for (const client of this.clients.values()) {
if (client.useHostIp) {
return true;
}
}
return false;
}
private getDesiredForwardingMode(hasHostIpClients = this.hasHostIpClients()): 'socket' | 'bridge' | 'hybrid' {
const configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
if (configuredMode !== 'socket') {
return configuredMode;
}
return hasHostIpClients ? 'hybrid' : 'socket';
}
private getDefaultDestinationPolicy( private getDefaultDestinationPolicy(
forwardingMode: 'socket' | 'bridge' | 'hybrid', forwardingMode: 'socket' | 'bridge' | 'hybrid',
useHostIp = false, useHostIp = false,
@@ -633,16 +655,45 @@ export class VpnManager {
}; };
} }
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> { private async restartWithForwardingMode(
if (!useHostIp || !this.vpnServer) return; forwardingMode: 'socket' | 'bridge' | 'hybrid',
if (this.getResolvedForwardingMode() !== 'socket') return; reason: string,
): Promise<void> {
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client'); logger.log('info', `VPN: Restarting server in ${forwardingMode} mode ${reason}`);
this.forwardingModeOverride = 'hybrid'; this.forwardingModeOverride = forwardingMode;
await this.stop(); await this.stop();
await this.start(); await this.start();
} }
private async ensureForwardingModeForNextClient(useHostIp: boolean): Promise<void> {
if (!this.vpnServer) return;
const desiredForwardingMode = this.getDesiredForwardingMode(this.hasHostIpClients(useHostIp));
if (desiredForwardingMode === this.getResolvedForwardingMode()) {
return;
}
await this.restartWithForwardingMode(desiredForwardingMode, 'to support a host-IP client');
}
private async reconcileForwardingMode(): Promise<boolean> {
if (!this.vpnServer) {
return false;
}
const desiredForwardingMode = this.getDesiredForwardingMode();
const currentForwardingMode = this.getResolvedForwardingMode();
if (desiredForwardingMode === currentForwardingMode) {
return false;
}
const reason = desiredForwardingMode === 'socket'
? 'because no host-IP clients remain'
: 'to support host-IP clients';
await this.restartWithForwardingMode(desiredForwardingMode, reason);
return true;
}
private async persistClient(client: VpnClientDoc): Promise<void> { private async persistClient(client: VpnClientDoc): Promise<void> {
await client.save(); await client.save();
} }

View File

@@ -1,8 +1,6 @@
# @serve.zone/dcrouter-apiclient # @serve.zone/dcrouter-apiclient
A typed, object-oriented API client for DcRouter with a fluent builder pattern. 🔧 Typed, object-oriented client for operating a running dcrouter instance. It wraps the OpsServer `/typedrequest` API in managers and resource classes so your scripts can work with routes, certificates, tokens, remote ingress edges, emails, stats, config, logs, and RADIUS without hand-rolling requests.
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.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -14,7 +12,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter-apiclient pnpm add @serve.zone/dcrouter-apiclient
``` ```
Or import directly from the main package: You can also import the same client through the main package subpath:
```typescript ```typescript
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient'; import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
@@ -23,243 +21,117 @@ import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
## Quick Start ## Quick Start
```typescript ```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', 'admin');
await client.login('admin', 'password');
// List routes
const { routes, warnings } = await client.routes.list(); const { routes, warnings } = await client.routes.list();
console.log(`${routes.length} routes, ${warnings.length} warnings`); console.log('route count', routes.length, 'warnings', warnings.length);
// Check health const route = await client.routes.build()
const { health } = await client.stats.getHealth(); .setName('api-gateway')
console.log(`Healthy: ${health.healthy}`); .setMatch({ ports: 443, domains: ['api.example.com'] })
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
.save();
await route.toggle(false);
``` ```
## Usage ## What the Client Gives You
### 🔐 Authentication | Manager | Purpose |
| --- | --- |
| `client.routes` | List merged routes, create API routes, toggle routes |
| `client.certificates` | Inspect certificates and run certificate operations |
| `client.apiTokens` | Create, list, toggle, roll, and revoke API tokens |
| `client.remoteIngress` | Manage edge registrations, statuses, and connection tokens |
| `client.emails` | Inspect email items and trigger resend flows |
| `client.stats` | Health, statistics, and operational summaries |
| `client.config` | Read the current configuration view |
| `client.logs` | Read recent logs and log-related data |
| `client.radius` | Manage RADIUS clients, VLANs, and sessions |
## Authentication Modes
| Mode | How it works |
| --- | --- |
| Admin login | Call `login(username, password)` and the returned identity is stored on the client |
| API token | Pass `apiToken` in the constructor and it is injected into requests automatically |
```typescript ```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({ const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com', baseUrl: 'https://dcrouter.example.com',
apiToken: 'dcr_your_token_here', apiToken: 'dcr_your_token_here',
}); });
``` ```
### 🌐 Routes — OO Resources + Builder Important behavior:
Routes are returned as `Route` instances with methods for update, delete, toggle, and overrides: - `baseUrl` is normalized, and the client automatically calls `${baseUrl}/typedrequest`
- `buildRequestPayload()` injects the current identity and optional API token for you
- system routes can be toggled, but only API routes are meant for edit and delete flows
```typescript ## Route Builder Example
// List all routes (hardcoded + programmatic)
const { routes, warnings } = 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();
```
**Builder pattern** for creating new routes:
```typescript ```typescript
const newRoute = await client.routes.build() const newRoute = await client.routes.build()
.setName('api-gateway') .setName('internal-app')
.setMatch({ ports: 443, domains: ['api.example.com'] }) .setMatch({
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] }) ports: 443,
.setTls({ mode: 'terminate', certificate: 'auto' }) domains: ['internal.example.com'],
})
.setAction({
type: 'forward',
targets: [{ host: '127.0.0.1', port: 3000 }],
})
.setEnabled(true) .setEnabled(true)
.save(); .save();
// Or use quick creation await newRoute.update({
const route = await client.routes.create(routeConfig); action: {
``` type: 'forward',
targets: [{ host: '127.0.0.1', port: 3001 }],
### 🔑 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
```typescript
const { certificates, summary } = await client.certificates.list();
console.log(`${summary.valid} valid, ${summary.expiring} expiring, ${summary.failed} 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: '...',
}); });
``` ```
### 🌍 Remote Ingress ## Token and Remote Ingress Example
```typescript ```typescript
// List edges and their statuses const token = await client.apiTokens.build()
const edges = await client.remoteIngress.list(); .setName('ci-token')
const statuses = await client.remoteIngress.getStatuses(); .setScopes(['routes:read', 'routes:write'])
.setExpiresInDays(30)
.save();
console.log('copy this once:', token.tokenValue);
// Create with builder
const edge = await client.remoteIngress.build() const edge = await client.remoteIngress.build()
.setName('edge-nyc-01') .setName('edge-eu-1')
.setListenPorts([80, 443]) .setListenPorts([80, 443])
.setAutoDerivePorts(true) .setAutoDerivePorts(true)
.setTags(['us-east']) .setTags(['production', 'eu'])
.save(); .save();
// Manage an edge const connectionToken = await edge.getConnectionToken();
await edge.update({ name: 'edge-nyc-02' }); console.log(connectionToken);
const newSecret = await edge.regenerateSecret();
const token = await edge.getConnectionToken();
await edge.delete();
``` ```
### 📊 Statistics (Read-Only) ## What This Package Does Not Do
```typescript - It does not start dcrouter.
const serverStats = await client.stats.getServer({ timeRange: '24h', includeHistory: true }); - It does not bundle the dashboard.
const emailStats = await client.stats.getEmail({ domain: 'example.com' }); - It does not replace the raw interfaces package when you want low-level TypedRequest contracts.
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 Use `@serve.zone/dcrouter` to run the server and `@serve.zone/dcrouter-interfaces` for the shared request/data types.
```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.
## License and Legal Information ## 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. 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. **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.

View File

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

View File

@@ -1,8 +1,6 @@
# @serve.zone/dcrouter-interfaces # @serve.zone/dcrouter-interfaces
TypeScript interfaces and type definitions for the DcRouter OpsServer API. 📡 Shared TypeScript contracts for dcrouter's TypedRequest API. Use this package when you want compile-time request/response types and shared data models without pulling in the higher-level client SDK.
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.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -14,324 +12,70 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter-interfaces pnpm add @serve.zone/dcrouter-interfaces
``` ```
Or import directly from the main package: You can also consume the same contracts through the main package subpath:
```typescript ```typescript
import { data, requests } from '@serve.zone/dcrouter/interfaces'; import { data, requests } from '@serve.zone/dcrouter/interfaces';
``` ```
## Usage ## What It Exports
| Export | Purpose |
| --- | --- |
| `data` | Shared runtime-shaped data such as identities, routes, stats, domains, DNS records, VPN data, remote ingress data, and email-domain data |
| `requests` | TypedRequest request/response contracts for OpsServer endpoints |
| `typedrequestInterfaces` | Re-exported helper types from `@api.global/typedrequest-interfaces` |
## API Surface Covered
| Domain | Examples |
| --- | --- |
| Auth | login, logout, identity verification |
| Routes | list merged routes, create, update, delete, toggle |
| Access | API tokens, source profiles, target profiles, network targets, users |
| DNS and domains | providers, domains, DNS records, ACME config |
| Email | email operations and email-domain management |
| Edge services | remote ingress, VPN, RADIUS |
| Observability | stats, health, logs, configuration |
## Quick Example
```typescript ```typescript
import { TypedRequest } from '@api.global/typedrequest';
import { data, requests } from '@serve.zone/dcrouter-interfaces'; import { data, requests } from '@serve.zone/dcrouter-interfaces';
// Use data interfaces for type definitions
const identity: data.IIdentity = { const identity: data.IIdentity = {
jwt: 'your-jwt-token', jwt: 'jwt-token',
userId: 'user-123', userId: 'admin-1',
name: 'Admin User', name: 'Admin',
expiresAt: Date.now() + 3600000, expiresAt: Date.now() + 60_000,
role: 'admin' role: 'admin',
type: 'user',
}; };
// Use request interfaces for API calls const request = new TypedRequest<requests.IReq_GetMergedRoutes>(
import * as typedrequest from '@api.global/typedrequest'; 'https://dcrouter.example.com/typedrequest',
'getMergedRoutes',
const statsClient = new typedrequest.TypedRequest<requests.IReq_GetServerStatistics>(
'https://your-dcrouter:3000/typedrequest',
'getServerStatistics'
); );
const stats = await statsClient.fire({ const response = await request.fire({ identity });
identity,
includeHistory: true,
timeRange: '24h'
});
```
## Module Structure for (const route of response.routes) {
console.log(route.id, route.origin, route.systemKey, route.enabled);
### 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
} }
``` ```
#### Statistics Interfaces ## When To Use This Package
| 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 |
#### Route Management Interfaces - Use it in tests that need strong request/response typing.
| Interface | Description | - Use it in custom CLIs or dashboards that call TypedRequest directly.
|-----------|-------------| - Use it in shared code where both client and server need the same data shapes.
| `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` |
#### Security & Reference Interfaces If you want managers, builders, and resource classes instead of raw contracts, use `@serve.zone/dcrouter-apiclient`.
| 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 |
#### 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` |
#### 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 |
### Request Interfaces (`requests`)
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);
```
## License and Legal Information ## 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. 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. **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.

View File

@@ -45,6 +45,33 @@ async function migrateTargetProfileTargetHosts(ctx: {
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`); 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. * Create a configured SmartMigration runner with all dcrouter migration steps registered.
* *
@@ -134,6 +161,12 @@ export async function createMigrationRunner(
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs') .description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
.up(async (ctx) => { .up(async (ctx) => {
await migrateTargetProfileTargetHosts(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; return migration;

77
ts_migrations/readme.md Normal file
View File

@@ -0,0 +1,77 @@
# @serve.zone/dcrouter-migrations
Versioned SmartMigration chain for dcrouter's persistent data. This package builds the migration runner that dcrouter executes before DB-backed managers start reading collections.
## 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.
## Installation
```bash
pnpm add @serve.zone/dcrouter-migrations
```
## What It Exports
| Export | Purpose |
| --- | --- |
| `createMigrationRunner(db, targetVersion)` | Builds the dcrouter migration runner for the target application version |
| `IMigrationRunner` | Small interface for the returned runner |
| `IMigrationRunResult` | Result shape logged after a run |
## When To Use It
- You are embedding dcrouter's storage layer outside the full runtime.
- You want to test or inspect schema transitions directly.
- You are extending dcrouter with new persistent data and need versioned upgrades.
If you boot the full `DcRouter` runtime, this package is already used for you during startup.
## Usage
```typescript
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
const migration = await createMigrationRunner(db, '13.20.0');
const result = await migration.run();
console.log(result.currentVersionBefore, result.currentVersionAfter);
```
## What the Current Chain Covers
- target profile target field migration from `host` to `ip`
- legacy domain source rename from `manual` to `dcrouter`
- legacy DNS record source rename from `manual` to `local`
- route storage unification from `StoredRouteDoc` to `RouteDoc`
- route `origin` backfill for migrated API routes
- `systemKey` backfill for persisted config, email, and DNS routes
## Authoring Rules
- Add new migration logic only in `ts_migrations/index.ts`.
- Keep every step idempotent so reruns are safe.
- Make each step's `.to()` version line up with the release version that ships it.
- When adding new collection references, use the exact smartdata class-name collection casing for new code.
## 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 = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.17.6', version: '13.20.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' 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 interfaces.requests.IReq_UpdateRoute
>('/typedrequest', 'updateRoute'); >('/typedrequest', 'updateRoute');
await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
route: dataArg.route, route: dataArg.route,
@@ -2158,6 +2158,10 @@ export const updateRouteAction = routeManagementStatePart.createAction<{
metadata: dataArg.metadata, metadata: dataArg.metadata,
}); });
if (!response.success) {
throw new Error(response.message || 'Failed to update route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) { } catch (error: unknown) {
return { return {
@@ -2177,11 +2181,15 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
interfaces.requests.IReq_DeleteRoute interfaces.requests.IReq_DeleteRoute
>('/typedrequest', 'deleteRoute'); >('/typedrequest', 'deleteRoute');
await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
id: routeId, id: routeId,
}); });
if (!response.success) {
throw new Error(response.message || 'Failed to delete route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) { } catch (error: unknown) {
return { return {
@@ -2204,12 +2212,16 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
interfaces.requests.IReq_ToggleRoute interfaces.requests.IReq_ToggleRoute
>('/typedrequest', 'toggleRoute'); >('/typedrequest', 'toggleRoute');
await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
if (!response.success) {
throw new Error(response.message || 'Failed to toggle route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) { } catch (error: unknown) {
return { return {
@@ -2765,4 +2777,4 @@ startAutoRefresh();
// Connect TypedSocket if already logged in (e.g., persistent session) // Connect TypedSocket if already logged in (e.g., persistent session)
if (loginStatePart.getState()!.isLoggedIn) { if (loginStatePart.getState()!.isLoggedIn) {
connectSocket(); connectSocket();
} }

View File

@@ -374,7 +374,7 @@ export class OpsViewNetworkActivity extends DeesElement {
type: 'number', type: 'number',
icon: 'lucide:Plug', icon: 'lucide:Plug',
color: activeConnections > 100 ? '#f59e0b' : '#22c55e', color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`, description: `Total: ${this.formatNumber(this.statsState.serverStats?.totalConnections || 0)} connections`,
actions: [ actions: [
{ {
name: 'View Details', name: 'View Details',

View File

@@ -15,16 +15,90 @@ import {
// TLS dropdown options shared by create and edit dialogs // TLS dropdown options shared by create and edit dialogs
const tlsModeOptions = [ const tlsModeOptions = [
{ key: 'none', option: '(none — no TLS)' }, { key: 'none', option: '(none — plain TCP/HTTP, use for SSH)' },
{ key: 'passthrough', option: 'Passthrough' }, { key: 'passthrough', option: 'Passthrough (TLS only)' },
{ key: 'terminate', option: 'Terminate' }, { key: 'terminate', option: 'Terminate TLS' },
{ key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt' }, { key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt TLS' },
]; ];
const tlsCertOptions = [ const tlsCertOptions = [
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' }, { key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
{ key: 'custom', option: 'Custom certificate' }, { key: 'custom', option: 'Custom certificate' },
]; ];
function getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
function parseTargetPort(value: any): number | undefined {
const parsed = typeof value === 'number'
? value
: typeof value === 'string'
? parseInt(value.trim(), 10)
: Number.NaN;
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
return undefined;
}
return parsed;
}
function getRouteTargetInputs(formEl: any) {
const textInputs = Array.from(formEl.querySelectorAll('dees-input-text')) as any[];
const checkboxInputs = Array.from(formEl.querySelectorAll('dees-input-checkbox')) as any[];
return {
hostInput: textInputs.find((input) => input.key === 'targetHost'),
portInput: textInputs.find((input) => input.key === 'targetPort'),
preservePortInput: checkboxInputs.find((input) => input.key === 'preserveMatchPort'),
};
}
function setupTargetInputState(formEl: any) {
const updateState = async () => {
const data = await formEl.collectFormData();
const contentEl = formEl.closest('.content') || formEl.parentElement;
const usesNetworkTarget = !!getDropdownKey(data.networkTargetRef);
const preserveMatchPort = !usesNetworkTarget && Boolean(data.preserveMatchPort);
const { hostInput, portInput, preservePortInput } = getRouteTargetInputs(formEl);
const hostDescription = usesNetworkTarget
? 'Controlled by the selected network target'
: 'Used when no network target is selected';
const portDescription = usesNetworkTarget
? 'Controlled by the selected network target'
: preserveMatchPort
? 'Forwarded to the backend on the same port the client matched'
: 'Used when no network target is selected';
if (hostInput) {
hostInput.disabled = usesNetworkTarget;
hostInput.required = !usesNetworkTarget;
hostInput.description = hostDescription;
}
if (portInput) {
portInput.disabled = usesNetworkTarget || preserveMatchPort;
portInput.required = !usesNetworkTarget && !preserveMatchPort;
portInput.description = portDescription;
}
if (preservePortInput) {
preservePortInput.disabled = usesNetworkTarget;
preservePortInput.description = usesNetworkTarget
? 'Unavailable when a network target is selected'
: 'Forward to the backend using the same port that matched this route';
if (usesNetworkTarget) {
preservePortInput.value = false;
}
}
const remoteIngressGroup = contentEl?.querySelector('.remoteIngressGroup') as HTMLElement | null;
if (remoteIngressGroup) {
remoteIngressGroup.style.display = Boolean(data.remoteIngressEnabled) ? 'flex' : 'none';
}
await formEl.updateRequiredStatus?.();
};
formEl.changeSubject.subscribe(() => updateState());
updateState();
}
/** /**
* Toggle TLS form field visibility based on selected TLS mode and certificate type. * Toggle TLS form field visibility based on selected TLS mode and certificate type.
*/ */
@@ -272,15 +346,13 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail; const clickedRoute = e.detail;
if (!clickedRoute) return; if (!clickedRoute) return;
// Find the corresponding merged route const merged = this.findMergedRoute(clickedRoute);
const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged) return; if (!merged) return;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const meta = merged.metadata; const meta = merged.metadata;
const isSystemManaged = this.isSystemManagedRoute(merged);
await DeesModal.createAndShow({ await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`, heading: `Route: ${merged.route.name}`,
content: html` content: html`
@@ -288,6 +360,7 @@ export class OpsViewRoutes extends DeesElement {
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p> <p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p> <p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.id}</code></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?.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>` : ''} ${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div> </div>
@@ -304,25 +377,29 @@ export class OpsViewRoutes extends DeesElement {
await modalArg.destroy(); await modalArg.destroy();
}, },
}, },
{ ...(!isSystemManaged
name: 'Edit', ? [
iconName: 'lucide:pencil', {
action: async (modalArg: any) => { name: 'Edit',
await modalArg.destroy(); iconName: 'lucide:pencil',
this.showEditRouteDialog(merged); action: async (modalArg: any) => {
}, await modalArg.destroy();
}, this.showEditRouteDialog(merged);
{ },
name: 'Delete', },
iconName: 'lucide:trash-2', {
action: async (modalArg: any) => { name: 'Delete',
await appstate.routeManagementStatePart.dispatchAction( iconName: 'lucide:trash-2',
appstate.deleteRouteAction, action: async (modalArg: any) => {
merged.id, await appstate.routeManagementStatePart.dispatchAction(
); appstate.deleteRouteAction,
await modalArg.destroy(); merged.id,
}, );
}, await modalArg.destroy();
},
},
]
: []),
{ {
name: 'Close', name: 'Close',
iconName: 'lucide:x', iconName: 'lucide:x',
@@ -336,10 +413,9 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail; const clickedRoute = e.detail;
if (!clickedRoute) return; if (!clickedRoute) return;
const merged = this.routeState.mergedRoutes.find( const merged = this.findMergedRoute(clickedRoute);
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged) return; if (!merged) return;
if (this.isSystemManagedRoute(merged)) return;
this.showEditRouteDialog(merged); this.showEditRouteDialog(merged);
} }
@@ -348,10 +424,9 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail; const clickedRoute = e.detail;
if (!clickedRoute) return; if (!clickedRoute) return;
const merged = this.routeState.mergedRoutes.find( const merged = this.findMergedRoute(clickedRoute);
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged) return; if (!merged) return;
if (this.isSystemManagedRoute(merged)) return;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({ await DeesModal.createAndShow({
@@ -410,10 +485,13 @@ export class OpsViewRoutes extends DeesElement {
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]) ? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
: []; : [];
const firstTarget = route.action.targets?.[0]; const firstTarget = route.action.targets?.[0];
const currentPreserveMatchPort = firstTarget?.port === 'preserve';
const currentTargetHost = firstTarget const currentTargetHost = firstTarget
? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host) ? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
: ''; : '';
const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : ''; const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
// Compute current TLS state for pre-population // Compute current TLS state for pre-population
const currentTls = (route.action as any).tls; const currentTls = (route.action as any).tls;
@@ -438,6 +516,11 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown> <dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text> <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text> <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
<div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'} .value=${currentEdgeFilter}></dees-input-list>
</div>
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown> <dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;"> <div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown> <dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
@@ -469,6 +552,24 @@ export class OpsViewRoutes extends DeesElement {
: []; : [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined; const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const profileKey = getDropdownKey(formData.sourceProfileRef);
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
? 'preserve'
: parseTargetPort(formData.targetPort)
?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined);
if (targetPort === undefined) {
alert('Target Port must be a valid port number when no network target is selected.');
return;
}
const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled);
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
? formData.remoteIngressEdgeFilter.filter(Boolean)
: [];
const updatedRoute: any = { const updatedRoute: any = {
name: formData.name, name: formData.name,
match: { match: {
@@ -479,11 +580,17 @@ export class OpsViewRoutes extends DeesElement {
type: 'forward', type: 'forward',
targets: [ targets: [
{ {
host: formData.targetHost || 'localhost', host: formData.targetHost || currentTargetHost || 'localhost',
port: parseInt(formData.targetPort, 10) || 443, port: targetPort,
}, },
], ],
}, },
remoteIngress: remoteIngressEnabled
? {
enabled: true,
...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}),
}
: null,
...(priority != null && !isNaN(priority) ? { priority } : {}), ...(priority != null && !isNaN(priority) ? { priority } : {}),
}; };
@@ -507,15 +614,17 @@ export class OpsViewRoutes extends DeesElement {
} }
const metadata: any = {}; const metadata: any = {};
const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) { if (profileKey) {
metadata.sourceProfileRef = profileKey; metadata.sourceProfileRef = profileKey;
} else if (merged.metadata?.sourceProfileRef) {
metadata.sourceProfileRef = '';
metadata.sourceProfileName = '';
} }
const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
if (targetKey) { if (targetKey) {
metadata.networkTargetRef = targetKey; metadata.networkTargetRef = targetKey;
} else if (merged.metadata?.networkTargetRef) {
metadata.networkTargetRef = '';
metadata.networkTargetName = '';
} }
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
@@ -536,6 +645,7 @@ export class OpsViewRoutes extends DeesElement {
if (editForm) { if (editForm) {
await editForm.updateComplete; await editForm.updateComplete;
setupTlsVisibility(editForm); setupTlsVisibility(editForm);
setupTargetInputState(editForm);
} }
} }
@@ -572,6 +682,11 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown> <dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text> <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text> <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
<div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'}></dees-input-list>
</div>
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown> <dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;"> <div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown> <dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
@@ -603,6 +718,24 @@ export class OpsViewRoutes extends DeesElement {
: []; : [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined; const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const profileKey = getDropdownKey(formData.sourceProfileRef);
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
? 'preserve'
: parseTargetPort(formData.targetPort)
?? (targetKey ? ports[0] : undefined);
if (targetPort === undefined) {
alert('Target Port must be a valid port number when no network target is selected.');
return;
}
const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled);
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
? formData.remoteIngressEdgeFilter.filter(Boolean)
: [];
const route: any = { const route: any = {
name: formData.name, name: formData.name,
match: { match: {
@@ -614,10 +747,18 @@ export class OpsViewRoutes extends DeesElement {
targets: [ targets: [
{ {
host: formData.targetHost || 'localhost', host: formData.targetHost || 'localhost',
port: parseInt(formData.targetPort, 10) || 443, port: targetPort,
}, },
], ],
}, },
...(remoteIngressEnabled
? {
remoteIngress: {
enabled: true,
...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}),
},
}
: {}),
...(priority != null && !isNaN(priority) ? { priority } : {}), ...(priority != null && !isNaN(priority) ? { priority } : {}),
}; };
@@ -640,13 +781,9 @@ export class OpsViewRoutes extends DeesElement {
// Build metadata if profile/target selected // Build metadata if profile/target selected
const metadata: any = {}; const metadata: any = {};
const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) { if (profileKey) {
metadata.sourceProfileRef = profileKey; metadata.sourceProfileRef = profileKey;
} }
const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
if (targetKey) { if (targetKey) {
metadata.networkTargetRef = targetKey; metadata.networkTargetRef = targetKey;
} }
@@ -668,6 +805,7 @@ export class OpsViewRoutes extends DeesElement {
if (createForm) { if (createForm) {
await createForm.updateComplete; await createForm.updateComplete;
setupTlsVisibility(createForm); setupTlsVisibility(createForm);
setupTargetInputState(createForm);
} }
} }
@@ -675,6 +813,23 @@ export class OpsViewRoutes extends DeesElement {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); 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() { async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);

View File

@@ -49,19 +49,28 @@ export class OpsViewVpn extends DeesElement {
@state() @state()
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!; accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
@state()
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
constructor() { constructor() {
super(); super();
const sub = appstate.vpnStatePart.select().subscribe((newState) => { const sub = appstate.vpnStatePart.select().subscribe((newState) => {
this.vpnState = newState; this.vpnState = newState;
}); });
this.rxSubscriptions.push(sub); this.rxSubscriptions.push(sub);
const targetProfilesSub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
this.targetProfilesState = newState;
});
this.rxSubscriptions.push(targetProfilesSub);
} }
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null); await Promise.all([
// Ensure target profiles are loaded for autocomplete candidates appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null),
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null); appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null),
]);
} }
public static styles = [ public static styles = [
@@ -330,13 +339,7 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml, 'Status': statusHtml,
'Routing': routingHtml, 'Routing': routingHtml,
'VPN IP': client.assignedIp || '-', 'VPN IP': client.assignedIp || '-',
'Target Profiles': client.targetProfileIds?.length 'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
? html`${client.targetProfileIds.map(id => {
const profileState = appstate.targetProfilesStatePart.getState();
const profile = profileState?.profiles.find(p => p.id === id);
return html`<span class="tagBadge">${profile?.name || id}</span>`;
})}`
: '-',
'Description': client.description || '-', 'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(), 'Created': new Date(client.createdAt).toLocaleDateString(),
}; };
@@ -347,6 +350,7 @@ export class OpsViewVpn extends DeesElement {
iconName: 'lucide:plus', iconName: 'lucide:plus',
type: ['header'], type: ['header'],
actionFunc: async () => { actionFunc: async () => {
await this.ensureTargetProfilesLoaded();
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const profileCandidates = this.getTargetProfileCandidates(); const profileCandidates = this.getTargetProfileCandidates();
const createModal = await DeesModal.createAndShow({ const createModal = await DeesModal.createAndShow({
@@ -647,6 +651,7 @@ export class OpsViewVpn extends DeesElement {
type: ['contextmenu', 'inRow'], type: ['contextmenu', 'inRow'],
actionFunc: async (actionData: any) => { actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient; const client = actionData.item as interfaces.data.IVpnClient;
await this.ensureTargetProfilesLoaded();
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? ''; const currentDescription = client.description ?? '';
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || []; const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
@@ -810,12 +815,28 @@ export class OpsViewVpn extends DeesElement {
`; `;
} }
private async ensureTargetProfilesLoaded(): Promise<void> {
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
}
private renderTargetProfileBadges(ids?: string[]): TemplateResult | string {
const labels = this.resolveProfileIdsToLabels(ids, {
pendingLabel: 'Loading profile...',
missingLabel: (id) => `Unknown profile (${id})`,
});
if (!labels?.length) {
return '-';
}
return html`${labels.map((label) => html`<span class="tagBadge">${label}</span>`)}`;
}
/** /**
* Build stable profile labels for list inputs. * Build stable profile labels for list inputs.
*/ */
private getTargetProfileChoices() { private getTargetProfileChoices() {
const profileState = appstate.targetProfilesStatePart.getState(); const profiles = this.targetProfilesState.profiles || [];
const profiles = profileState?.profiles || [];
const nameCounts = new Map<string, number>(); const nameCounts = new Map<string, number>();
for (const profile of profiles) { for (const profile of profiles) {
@@ -837,12 +858,27 @@ export class OpsViewVpn extends DeesElement {
/** /**
* Convert profile IDs to form labels (for populating edit form values). * Convert profile IDs to form labels (for populating edit form values).
*/ */
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined { private resolveProfileIdsToLabels(
ids?: string[],
options: {
pendingLabel?: string;
missingLabel?: (id: string) => string;
} = {},
): string[] | undefined {
if (!ids?.length) return undefined; if (!ids?.length) return undefined;
const choices = this.getTargetProfileChoices(); const choices = this.getTargetProfileChoices();
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label])); const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
return ids.map((id) => { return ids.map((id) => {
return labelsById.get(id) || id; const label = labelsById.get(id);
if (label) {
return label;
}
if (this.targetProfilesState.lastUpdated === 0 && !this.targetProfilesState.error) {
return options.pendingLabel || 'Loading profile...';
}
return options.missingLabel?.(id) || id;
}); });
} }

View File

@@ -1,277 +1,56 @@
# @serve.zone/dcrouter-web # @serve.zone/dcrouter-web
Web-based Operations Dashboard for DcRouter. 🖥️ Browser-side frontend for the dcrouter Ops dashboard. This folder is the SPA entrypoint, router, app state, and web-component UI rendered by OpsServer.
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).
## Issue Reporting and Security ## 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. 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 It Boots
### 🔐 Secure Authentication - `index.ts` initializes the app router and renders `<ops-dashboard>` into `document.body`
- JWT-based login with persistent sessions (IndexedDB) - `router.ts` defines top-level dashboard routes and subviews
- Automatic session expiry detection and cleanup - `appstate.ts` holds reactive state, TypedRequest actions, and TypedSocket log streaming
- Secure username/password authentication - `elements/` contains the dashboard shell and feature views
### 📊 Overview Dashboard ## View Map
- 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
### 🌐 Network View | Top-level view | Subviews |
- Active connection monitoring with real-time data from SmartProxy | --- | --- |
- Top connected IPs with connection counts and percentages | `overview` | `stats`, `configuration` |
- Throughput rates (inbound/outbound in kbit/s, Mbit/s, Gbit/s) | `network` | `activity`, `routes`, `sourceprofiles`, `networktargets`, `targetprofiles`, `remoteingress`, `vpn` |
- Traffic chart with selectable time ranges | `email` | `log`, `security`, `domains` |
| `access` | `apitokens`, `users` |
| `security` | `overview`, `blocked`, `authentication` |
| `domains` | `providers`, `domains`, `dns`, `certificates` |
| `logs` | flat view |
### 📧 Email Management ## How It Talks To dcrouter
- **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
### 🔐 Certificate Management - TypedRequest for the main API surface
- Domain-centric certificate overview with status indicators - shared request and data contracts from `@serve.zone/dcrouter-interfaces`
- Certificate source tracking (ACME, provision function, static) - TypedSocket for real-time log streaming
- Expiry date monitoring and alerts - QR code generation for VPN client UX
- Per-domain backoff status for failed provisions
- One-click reprovisioning per domain
- Certificate import, export, and deletion
### 🌍 Remote Ingress Management ## Development Notes
- 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
### 🔐 VPN Management This package is the frontend module boundary, but it is built and served as part of the main workspace.
- 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
### 📜 Log Viewer
- Real-time log streaming
- Filter by log level (error, warning, info, debug)
- Search and time-range selection
### 🛣️ 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
### 🛡️ 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
### ⚙️ 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
### 🛡️ Security Dashboard
- IP reputation monitoring
- Rate limit status across domains
- Blocked connection tracking
- Security event timeline
## 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
```bash ```bash
# Build the bundle pnpm run build
pnpm run bundle
# Watch for development (auto-rebuild + restart)
pnpm run watch pnpm run watch
``` ```
The bundle is output to `./dist_serve/bundle.js` and served by the OpsServer. The built dashboard assets are emitted into `dist_serve/` by the workspace build pipeline.
### Adding a New View ## What This Package Is For
1. Create a view component in `elements/`: - Use it when you want the dashboard frontend as its own published module boundary.
```typescript - Use `@serve.zone/dcrouter` when you want the server that actually hosts this UI and the backend API.
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`
## License and Legal Information ## 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. 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. **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.