Compare commits

..

16 Commits

Author SHA1 Message Date
20ea0ce683 v13.17.8
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 01:16:37 +00:00
bcea93753b fix(opsserver): align certificate status handling with the updated smartproxy response format 2026-04-14 01:16:37 +00:00
848515e424 v13.17.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:56:31 +00:00
38c9978969 fix(repo): no changes to commit 2026-04-14 00:56:31 +00:00
ee863b8178 v13.17.6
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:53:26 +00:00
9bb5a8bcc1 fix(dns,routes): keep DoH socket-handler routes runtime-only and prune stale persisted entries 2026-04-14 00:53:26 +00:00
5aa07e81c7 v13.17.5
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 23:02:42 +00:00
aec8b72ca3 fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior 2026-04-13 23:02:42 +00:00
466654ee4c v13.17.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:46:12 +00:00
f1a11e3f6a fix(ops-view-routes): sync route filter toggle selection via component changeSubject 2026-04-13 19:46:12 +00:00
e193b3a8eb v13.17.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:17:46 +00:00
1bbf31605c fix(monitoring): exclude unconfigured routes from domain activity aggregation 2026-04-13 19:17:46 +00:00
f2cfa923a0 v13.17.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:15:46 +00:00
cdc77305e5 fix(monitoring): stop allocating route metrics to domains when no request data exists 2026-04-13 19:15:46 +00:00
835537f789 v13.17.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-13 19:12:56 +00:00
754b223f62 feat(monitoring,network-ui,routes): add request-based domain activity metrics and split routes into user and system views 2026-04-13 19:12:56 +00:00
20 changed files with 923 additions and 295 deletions

View File

@@ -1,5 +1,58 @@
# Changelog
## 2026-04-14 - 13.17.8 - fix(opsserver)
align certificate status handling with the updated smartproxy response format
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
- bump @push.rocks/smartproxy to ^27.7.3
- enable verbose output for the test script
## 2026-04-14 - 13.17.7 - fix(repo)
no changes to commit
## 2026-04-14 - 13.17.6 - fix(dns,routes)
keep DoH socket-handler routes runtime-only and prune stale persisted entries
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
normalize target profile route references and stabilize VPN host-IP client routing behavior
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
sync route filter toggle selection via component changeSubject
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
## 2026-04-13 - 13.17.2 - fix(monitoring)
exclude unconfigured routes from domain activity aggregation
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
## 2026-04-13 - 13.17.1 - fix(monitoring)
stop allocating route metrics to domains when no request data exists
- Removes the equal-split fallback for shared routes in MetricsManager.
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
add request-based domain activity metrics and split routes into user and system views
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
## 2026-04-13 - 13.16.2 - fix(deps)
bump @push.rocks/smartproxy to ^27.6.0

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.16.2",
"version": "13.17.8",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -12,7 +12,7 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --logfile --timeout 60)",
"test": "(tstest test/ --verbose --logfile --timeout 60)",
"start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
@@ -51,10 +51,10 @@
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartnetwork": "^4.6.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.6.0",
"@push.rocks/smartproxy": "^27.7.3",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
@@ -62,12 +62,12 @@
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.2",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.3",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.3.3",
"lru-cache": "^11.3.5",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
},

67
pnpm-lock.yaml generated
View File

@@ -72,8 +72,8 @@ importers:
specifier: ^5.3.1
version: 5.3.1
'@push.rocks/smartnetwork':
specifier: ^4.5.2
version: 4.5.2
specifier: ^4.6.0
version: 4.6.0
'@push.rocks/smartpath':
specifier: ^6.0.0
version: 6.0.0
@@ -81,8 +81,8 @@ importers:
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartproxy':
specifier: ^27.6.0
version: 27.6.0
specifier: ^27.7.3
version: 27.7.3
'@push.rocks/smartradius':
specifier: ^1.1.1
version: 1.1.1
@@ -105,8 +105,8 @@ importers:
specifier: ^8.0.2
version: 8.0.2
'@serve.zone/catalog':
specifier: ^2.12.3
version: 2.12.3(@tiptap/pm@2.27.2)
specifier: ^2.12.4
version: 2.12.4(@tiptap/pm@2.27.2)
'@serve.zone/interfaces':
specifier: ^5.3.0
version: 5.3.0
@@ -120,8 +120,8 @@ importers:
specifier: ^1.5.6
version: 1.5.6
lru-cache:
specifier: ^11.3.3
version: 11.3.3
specifier: ^11.3.5
version: 11.3.5
qrcode:
specifier: ^1.5.4
version: 1.5.4
@@ -365,9 +365,6 @@ packages:
'@design.estate/dees-element@2.2.4':
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
'@design.estate/dees-wcctools@3.8.0':
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
'@design.estate/dees-wcctools@3.9.0':
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
@@ -1260,8 +1257,8 @@ packages:
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
'@push.rocks/smartnetwork@4.5.2':
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
'@push.rocks/smartnetwork@4.6.0':
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
'@push.rocks/smartnftables@1.1.0':
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
@@ -1287,8 +1284,8 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@27.6.0':
resolution: {integrity: sha512-1mPzabUKhlC0EdeI7Hjee/aiptTsOLftbq8oWBTlIg9JhCQwkIs5UNGTJV/VvlEflJKnay8TbzLzlr95gUr/1w==}
'@push.rocks/smartproxy@27.7.3':
resolution: {integrity: sha512-1NER+2nM2GWzrMB1xoCzkV9in8a1gWNwOimgE/f2kPYnhy5DsA2XX/KiLGJ0ge3cpCVgoK1nNe3byXQHWvmPbA==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1591,8 +1588,8 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@serve.zone/catalog@2.12.3':
resolution: {integrity: sha512-/QLFjFcy/ig6cdr4517smSc/VCutW/qF/8lCM3v7tpQ5yLApjqiL314Dyvk9zzSwHpw69IeuM9EmPOeTuCY0iQ==}
'@serve.zone/catalog@2.12.4':
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
'@serve.zone/interfaces@5.3.0':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
@@ -3085,8 +3082,8 @@ packages:
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lru-cache@11.3.3:
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
lru-cache@11.3.5:
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
engines: {node: 20 || >=22}
lru-cache@7.18.3:
@@ -4937,18 +4934,6 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-wcctools@3.8.0':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@push.rocks/smartdelay': 3.0.5
lit: 3.3.2
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@design.estate/dees-wcctools@3.9.0':
dependencies:
'@design.estate/dees-domtools': 2.5.4
@@ -5169,7 +5154,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
'@push.rocks/smartnetwork': 4.5.2
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
@@ -5972,7 +5957,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 7.9.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartnetwork': 4.5.2
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
@@ -6429,7 +6414,7 @@ snapshots:
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2
'@tsclass/tsclass': 9.5.0
lru-cache: 11.3.3
lru-cache: 11.3.5
mailparser: 3.9.6
uuid: 13.0.0
transitivePeerDependencies:
@@ -6439,7 +6424,7 @@ snapshots:
dependencies:
handlebars: 4.7.9
'@push.rocks/smartnetwork@4.5.2':
'@push.rocks/smartnetwork@4.6.0':
dependencies:
'@push.rocks/smartdns': 7.9.0
'@push.rocks/smartrust': 1.3.2
@@ -6500,7 +6485,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfs': 1.5.0
'@push.rocks/smartjimp': 1.2.0
'@push.rocks/smartnetwork': 4.5.2
'@push.rocks/smartnetwork': 4.6.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
@@ -6521,7 +6506,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.6.0':
'@push.rocks/smartproxy@27.7.3':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -6923,12 +6908,12 @@ snapshots:
domhandler: 5.0.3
selderee: 0.11.0
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
'@serve.zone/catalog@2.12.4(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
'@design.estate/dees-wcctools': 3.9.0
transitivePeerDependencies:
- '@nuxt/kit'
- '@tiptap/pm'
@@ -8659,7 +8644,7 @@ snapshots:
lowercase-keys@3.0.0: {}
lru-cache@11.3.3: {}
lru-cache@11.3.5: {}
lru-cache@7.18.3: {}
@@ -9304,7 +9289,7 @@ snapshots:
path-scurry@2.0.2:
dependencies:
lru-cache: 11.3.3
lru-cache: 11.3.5
minipass: 7.1.3
path-to-regexp@8.4.2: {}

View File

@@ -0,0 +1,230 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DnsManager } from '../ts/dns/manager.dns.js';
import { logger } from '../ts/logger.js';
import * as plugins from '../ts/plugins.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const route of await RouteDoc.findAll()) {
await route.delete();
}
for (const domain of await DomainDoc.findAll()) {
await domain.delete();
}
};
tap.test('RouteConfigManager applies runtime DoH routes without persisting them', async () => {
await testDbPromise;
await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
undefined,
undefined,
() => (dcRouter as any).generateDnsRoutes(),
);
await routeManager.initialize([], [], []);
await routeManager.applyRoutes();
const persistedRoutes = await RouteDoc.findAll();
expect(persistedRoutes.length).toEqual(0);
expect(appliedRoutes.length).toEqual(2);
for (const routeSet of appliedRoutes) {
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
expect(dnsQueryRoute).toBeDefined();
expect(resolveRoute).toBeDefined();
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
}
});
tap.test('RouteConfigManager removes stale persisted DoH socket-handler routes on startup', async () => {
await testDbPromise;
await clearTestState();
const staleDnsQueryRoute = new RouteDoc();
staleDnsQueryRoute.id = 'stale-doh-query';
staleDnsQueryRoute.route = {
name: 'dns-over-https-dns-query',
match: {
ports: [443],
domains: ['ns1.example.com'],
path: '/dns-query',
},
action: {
type: 'socket-handler' as any,
} as any,
};
staleDnsQueryRoute.enabled = true;
staleDnsQueryRoute.createdAt = Date.now();
staleDnsQueryRoute.updatedAt = Date.now();
staleDnsQueryRoute.createdBy = 'test';
staleDnsQueryRoute.origin = 'dns';
await staleDnsQueryRoute.save();
const staleResolveRoute = new RouteDoc();
staleResolveRoute.id = 'stale-doh-resolve';
staleResolveRoute.route = {
name: 'dns-over-https-resolve',
match: {
ports: [443],
domains: ['ns1.example.com'],
path: '/resolve',
},
action: {
type: 'socket-handler' as any,
} as any,
};
staleResolveRoute.enabled = true;
staleResolveRoute.createdAt = Date.now();
staleResolveRoute.updatedAt = Date.now();
staleResolveRoute.createdBy = 'test';
staleResolveRoute.origin = 'dns';
await staleResolveRoute.save();
const validRoute = new RouteDoc();
validRoute.id = 'valid-forward-route';
validRoute.route = {
name: 'valid-forward-route',
match: {
ports: [443],
domains: ['app.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
tls: { mode: 'terminate' as const },
},
} as any;
validRoute.enabled = true;
validRoute.createdAt = Date.now();
validRoute.updatedAt = Date.now();
validRoute.createdBy = 'test';
validRoute.origin = 'api';
await validRoute.save();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const routeManager = new RouteConfigManager(() => smartProxy as any);
await routeManager.initialize([], [], []);
expect((await RouteDoc.findByName('dns-over-https-dns-query'))).toEqual(null);
expect((await RouteDoc.findByName('dns-over-https-resolve'))).toEqual(null);
const remainingRoutes = await RouteDoc.findAll();
expect(remainingRoutes.length).toEqual(1);
expect(remainingRoutes[0].route.name).toEqual('valid-forward-route');
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(1);
expect(appliedRoutes[0][0].name).toEqual('valid-forward-route');
});
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
await testDbPromise;
await clearTestState();
const originalLog = logger.log.bind(logger);
const warningMessages: string[] = [];
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
if (level === 'warn') {
warningMessages.push(message);
}
return originalLog(level, message, context || {});
};
try {
const existingDomain = new DomainDoc();
existingDomain.id = 'existing-domain';
existingDomain.name = 'example.com';
existingDomain.source = 'dcrouter';
existingDomain.authoritative = true;
existingDomain.createdAt = Date.now();
existingDomain.updatedAt = Date.now();
existingDomain.createdBy = 'test';
await existingDomain.save();
const dnsManager = new DnsManager({
dnsNsDomains: ['ns1.example.com'],
dnsScopes: ['example.com'],
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
smartProxyConfig: { routes: [] },
});
await dnsManager.start();
expect(
warningMessages.some((message) =>
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
),
).toEqual(true);
expect(
warningMessages.some((message) =>
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
),
).toEqual(false);
} finally {
(logger as any).log = originalLog;
}
});
tap.test('cleanup test db', async () => {
await clearTestState();
const testDb = await testDbPromise;
await testDb.cleanup();
});
export default tap.start();

View File

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

View File

@@ -315,7 +315,8 @@ export class DcRouter {
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Runtime-only DoH routes. These carry live socket handlers and must never be persisted.
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -547,7 +548,9 @@ export class DcRouter {
await this.referenceResolver.initialize();
// Initialize target profile manager
this.targetProfileManager = new TargetProfileManager();
this.targetProfileManager = new TargetProfileManager(
() => this.routeConfigManager?.getRoutes() || new Map(),
);
await this.targetProfileManager.initialize();
this.routeConfigManager = new RouteConfigManager(
@@ -560,7 +563,10 @@ export class DcRouter {
return [];
}
return this.targetProfileManager.getMatchingClientIps(
route, routeId, this.vpnManager.listClients(),
route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
);
}
: undefined,
@@ -575,14 +581,15 @@ export class DcRouter {
this.tunnelManager.syncAllowedEdges();
}
},
() => this.runtimeDnsRoutes,
);
this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
await this.targetProfileManager.normalizeAllRouteRefs();
// Seed default profiles/targets if DB is empty and seeding is enabled
const seeder = new DbSeeder(this.referenceResolver);
@@ -886,7 +893,7 @@ export class DcRouter {
this.smartProxy = undefined;
}
// Assemble seed routes from constructor config — these will be seeded into DB
// Assemble serializable seed routes from constructor config — these will be seeded into DB
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
@@ -897,17 +904,17 @@ export class DcRouter {
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
}
this.seedDnsRoutes = [];
this.runtimeDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
this.seedDnsRoutes = this.generateDnsRoutes();
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
this.runtimeDnsRoutes = this.generateDnsRoutes();
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) });
}
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
let routes: plugins.smartproxy.IRouteConfig[] = [
...this.seedConfigRoutes,
...this.seedEmailRoutes,
...this.seedDnsRoutes,
...this.runtimeDnsRoutes,
];
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
@@ -1457,7 +1464,6 @@ export class DcRouter {
await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
}
@@ -2179,7 +2185,7 @@ export class DcRouter {
// Pass current bootstrap routes so the manager can derive edge ports initially.
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
// will push the complete merged routes here.
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.seedDnsRoutes];
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
// If ConfigManagers finished before us, re-apply routes
@@ -2283,8 +2289,11 @@ export class DcRouter {
// Resolve DNS A records for matched domains (with caching)
for (const domain of domains) {
const stripped = domain.replace(/^\*\./, '');
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
if (this.isWildcardVpnDomain(domain)) {
this.logSkippedWildcardAllowedIp(domain);
continue;
}
const resolvedIps = await this.resolveVpnDomainIPs(domain);
for (const ip of resolvedIps) {
ips.add(`${ip}/32`);
}
@@ -2303,6 +2312,8 @@ export class DcRouter {
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
private warnedWildcardVpnDomains = new Set<string>();
/**
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
@@ -2328,6 +2339,19 @@ export class DcRouter {
}
}
private isWildcardVpnDomain(domain: string): boolean {
return domain.includes('*');
}
private logSkippedWildcardAllowedIp(domain: string): void {
if (this.warnedWildcardVpnDomains.has(domain)) return;
this.warnedWildcardVpnDomains.add(domain);
logger.log(
'warn',
`VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
);
}
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
// via the getVpnAllowList callback — no longer a separate method here.

View File

@@ -55,6 +55,7 @@ export class RouteConfigManager {
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
) {}
/** Expose routes map for reference resolution lookups. */
@@ -63,7 +64,8 @@ export class RouteConfigManager {
}
/**
* Load persisted routes, seed config/email/dns routes, compute warnings, apply to SmartProxy.
* Load persisted routes, seed serializable config/email/dns routes,
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
*/
public async initialize(
configRoutes: IDcRouterRouteConfig[] = [],
@@ -284,23 +286,40 @@ export class RouteConfigManager {
private async loadRoutes(): Promise<void> {
const docs = await RouteDoc.findAll();
let prunedRuntimeRoutes = 0;
for (const doc of docs) {
if (doc.id) {
this.routes.set(doc.id, {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
origin: doc.origin || 'api',
metadata: doc.metadata,
});
if (!doc.id) continue;
const storedRoute: IRoute = {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
origin: doc.origin || 'api',
metadata: doc.metadata,
};
if (this.isPersistedRuntimeRoute(storedRoute)) {
await doc.delete();
prunedRuntimeRoutes++;
logger.log(
'warn',
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
);
continue;
}
this.routes.set(doc.id, storedRoute);
}
if (this.routes.size > 0) {
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> {
@@ -389,36 +408,18 @@ export class RouteConfigManager {
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const http3Config = this.getHttp3Config?.();
const vpnCallback = this.getVpnClientIpsForRoute;
// Helper: inject VPN security into a vpnOnly route
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnEntries = vpnCallback(dcRoute, routeId);
const existingEntries = route.security?.ipAllowList || [];
return {
...route,
security: {
...route.security,
ipAllowList: [...existingEntries, ...vpnEntries],
},
};
};
// Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
let r = route.route;
if (http3Config?.enabled !== false) {
r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config });
}
enabledRoutes.push(injectVpn(r, route.id));
enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
}
}
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
for (const route of runtimeRoutes) {
enabledRoutes.push(this.prepareRouteForApply(route));
}
await smartProxy.updateRoutes(enabledRoutes);
// Notify listeners (e.g. RemoteIngressManager) of the route set
@@ -429,4 +430,47 @@ export class RouteConfigManager {
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
});
}
private prepareRouteForApply(
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
let preparedRoute = route;
const http3Config = this.getHttp3Config?.();
if (http3Config?.enabled !== false) {
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
}
return this.injectVpnSecurity(preparedRoute, routeId);
}
private injectVpnSecurity(
route: plugins.smartproxy.IRouteConfig,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const vpnCallback = this.getVpnClientIpsForRoute;
if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnEntries = vpnCallback(dcRoute, routeId);
const existingEntries = route.security?.ipAllowList || [];
return {
...route,
security: {
...route.security,
ipAllowList: [...existingEntries, ...vpnEntries],
},
};
}
private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
const routeName = storedRoute.route.name || '';
const actionType = storedRoute.route.action?.type;
return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
|| (storedRoute.origin === 'dns' && actionType === 'socket-handler');
}
}

View File

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

View File

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

View File

@@ -560,7 +560,7 @@ export class MetricsManager {
requestsPerSecond: 0,
requestsTotal: 0,
backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number }>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>,
};
}
@@ -720,11 +720,20 @@ export class MetricsManager {
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
// Build domain activity from per-route metrics
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Map route name → ALL its domains (not just the first one)
// Aggregate per-IP domain request counts into per-domain totals
const domainRequestTotals = new Map<string, number>();
const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP();
for (const [, domainMap] of domainRequestsByIP) {
for (const [domain, count] of domainMap) {
domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count);
}
}
// Map route name → domains from route config
const routeDomains = new Map<string, string[]>();
if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
@@ -738,34 +747,26 @@ export class MetricsManager {
}
}
// Use protocol cache to discover actual active domains (resolves wildcards)
const activeDomains = new Set<string>();
const domainToBackend = new Map<string, string>(); // domain → host:port
// Resolve wildcards using domains seen in request metrics
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
for (const entry of protocolCache) {
if (entry.domain) {
activeDomains.add(entry.domain);
domainToBackend.set(entry.domain, `${entry.host}:${entry.port}`);
}
if (entry.domain) allKnownDomains.add(entry.domain);
}
// Build reverse map: domain → route name(s) that handle it
// For concrete domains: direct lookup from route config
// For wildcard patterns: match active domains from protocol cache
// Build reverse map: concrete domain → route name(s)
const domainToRoutes = new Map<string, string[]>();
for (const [routeName, domains] of routeDomains) {
for (const pattern of domains) {
if (pattern.includes('*')) {
// Wildcard pattern — match against active domains from protocol cache
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const activeDomain of activeDomains) {
if (regex.test(activeDomain)) {
const existing = domainToRoutes.get(activeDomain);
for (const knownDomain of allKnownDomains) {
if (regex.test(knownDomain)) {
const existing = domainToRoutes.get(knownDomain);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(activeDomain, [routeName]); }
else { domainToRoutes.set(knownDomain, [routeName]); }
}
}
} else {
// Concrete domain
const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(pattern, [routeName]); }
@@ -773,37 +774,40 @@ export class MetricsManager {
}
}
// Aggregate metrics per domain
// For each domain, sum metrics from all routes that serve it,
// divided by the number of domains each route serves
// For each route, compute the total request count across all its resolved domains
// so we can distribute throughput/connections proportionally
const routeTotalRequests = new Map<string, number>();
for (const [domain, routeNames] of domainToRoutes) {
const reqs = domainRequestTotals.get(domain) || 0;
for (const routeName of routeNames) {
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 0) + reqs);
}
}
// Aggregate metrics per domain using request-count-proportional splitting
const domainAgg = new Map<string, {
activeConnections: number;
bytesInPerSec: number;
bytesOutPerSec: number;
routeCount: number;
requestCount: number;
}>();
// Track which routes are accounted for
const accountedRoutes = new Set<string>();
for (const [domain, routeNames] of domainToRoutes) {
const domainReqs = domainRequestTotals.get(domain) || 0;
let totalConns = 0;
let totalIn = 0;
let totalOut = 0;
for (const routeName of routeNames) {
accountedRoutes.add(routeName);
const conns = connectionsByRoute.get(routeName) || 0;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
// Count how many resolved domains share this route
let domainsInRoute = 0;
for (const [, routes] of domainToRoutes) {
if (routes.includes(routeName)) domainsInRoute++;
}
const share = Math.max(domainsInRoute, 1);
totalConns += conns / share;
totalIn += tp.in / share;
totalOut += tp.out / share;
const routeTotal = routeTotalRequests.get(routeName) || 0;
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
totalConns += conns * share;
totalIn += tp.in * share;
totalOut += tp.out * share;
}
domainAgg.set(domain, {
@@ -811,31 +815,10 @@ export class MetricsManager {
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeNames.length,
requestCount: domainReqs,
});
}
// Include routes with no domain config (fallback: use route name)
for (const [routeName, activeConns] of connectionsByRoute) {
if (accountedRoutes.has(routeName)) continue;
if (routeDomains.has(routeName)) continue; // has domains but no traffic matched
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
const existing = domainAgg.get(routeName);
if (existing) {
existing.activeConnections += activeConns;
existing.bytesInPerSec += tp.in;
existing.bytesOutPerSec += tp.out;
existing.routeCount++;
} else {
domainAgg.set(routeName, {
activeConnections: activeConns,
bytesInPerSec: tp.in,
bytesOutPerSec: tp.out,
routeCount: 1,
});
}
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
@@ -843,6 +826,7 @@ export class MetricsManager {
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
requestCount: data.requestCount,
}))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));

View File

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

View File

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

View File

@@ -149,6 +149,7 @@ export interface IDomainActivity {
bytesOutPerSecond: number;
activeConnections: number;
routeCount: number;
requestCount: number;
}
export interface INetworkMetrics {

View File

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

View File

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

View File

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

View File

@@ -560,11 +560,12 @@ export class OpsViewNetworkActivity extends DeesElement {
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
'Transferred / min': this.formatBytes(totalBytesPerMin),
'Connections': item.activeConnections,
'Requests': item.requestCount?.toLocaleString() ?? '0',
'Routes': item.routeCount,
};
}}
heading1="Domain Activity"
heading2="Per-domain network activity aggregated from route metrics"
heading2="Per-domain network activity from request-level metrics"
searchable
.showColumnFilters=${true}
.pagination=${false}

View File

@@ -49,6 +49,8 @@ function setupTlsVisibility(formEl: any) {
@customElement('ops-view-routes')
export class OpsViewRoutes extends DeesElement {
@state() accessor routeFilter: 'User Routes' | 'System Routes' = 'User Routes';
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
@@ -156,20 +158,20 @@ export class OpsViewRoutes extends DeesElement {
},
{
id: 'configRoutes',
title: 'From Config',
title: 'System Routes',
type: 'number',
value: configCount,
icon: 'lucide:settings',
description: 'Seeded from config/email/DNS',
description: 'From config, email, and DNS',
color: '#8b5cf6',
},
{
id: 'apiRoutes',
title: 'API Created',
title: 'User Routes',
type: 'number',
value: apiCount,
icon: 'lucide:code',
description: 'Routes added via API',
description: 'Created via API',
color: '#0ea5e9',
},
{
@@ -183,8 +185,14 @@ export class OpsViewRoutes extends DeesElement {
},
];
// Map merged routes to sz-route-list-view format
const szRoutes = mergedRoutes.map((mr) => {
// Filter routes based on selected tab
const isUserRoutes = this.routeFilter === 'User Routes';
const filteredRoutes = mergedRoutes.filter((mr) =>
isUserRoutes ? mr.origin === 'api' : mr.origin !== 'api'
);
// Map filtered routes to sz-route-list-view format
const szRoutes = filteredRoutes.map((mr) => {
const tags = [...(mr.route.tags || [])];
tags.push(mr.origin);
if (!mr.enabled) tags.push('disabled');
@@ -218,6 +226,13 @@ export class OpsViewRoutes extends DeesElement {
]}
></dees-statsgrid>
<dees-input-multitoggle
class="routeFilterToggle"
.type=${'single'}
.options=${['User Routes', 'System Routes']}
.selectedOption=${this.routeFilter}
></dees-input-multitoggle>
${warnings.length > 0
? html`
<div class="warnings-bar">
@@ -237,6 +252,7 @@ export class OpsViewRoutes extends DeesElement {
? html`
<sz-route-list-view
.routes=${szRoutes}
.showActionsFilter=${isUserRoutes ? () => true : () => false}
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
@@ -244,8 +260,8 @@ export class OpsViewRoutes extends DeesElement {
`
: html`
<div class="empty-state">
<p>No routes configured</p>
<p>Add a route to get started.</p>
<p>No ${isUserRoutes ? 'user' : 'system'} routes</p>
<p>${isUserRoutes ? 'Add a route to get started.' : 'System routes are generated from config, email, and DNS settings.'}</p>
</div>
`}
</div>
@@ -661,5 +677,13 @@ export class OpsViewRoutes extends DeesElement {
async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
const toggle = this.shadowRoot!.querySelector('.routeFilterToggle') as any;
if (toggle) {
const sub = toggle.changeSubject.subscribe(() => {
this.routeFilter = toggle.selectedOption;
});
this.rxSubscriptions.push(sub);
}
}
}

View File

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

View File

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