Compare commits

...

30 Commits

Author SHA1 Message Date
9a378ae87f v13.17.9
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 09:33:41 +00:00
58fbc2b1e4 fix(monitoring): align domain activity metrics with id-keyed route data 2026-04-14 09:33:41 +00:00
20ea0ce683 v13.17.8
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 01:16:37 +00:00
bcea93753b fix(opsserver): align certificate status handling with the updated smartproxy response format 2026-04-14 01:16:37 +00:00
848515e424 v13.17.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:56:31 +00:00
38c9978969 fix(repo): no changes to commit 2026-04-14 00:56:31 +00:00
ee863b8178 v13.17.6
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-14 00:53:26 +00:00
9bb5a8bcc1 fix(dns,routes): keep DoH socket-handler routes runtime-only and prune stale persisted entries 2026-04-14 00:53:26 +00:00
5aa07e81c7 v13.17.5
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 23:02:42 +00:00
aec8b72ca3 fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior 2026-04-13 23:02:42 +00:00
466654ee4c v13.17.3
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:46:12 +00:00
f1a11e3f6a fix(ops-view-routes): sync route filter toggle selection via component changeSubject 2026-04-13 19:46:12 +00:00
e193b3a8eb v13.17.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 19:17:46 +00:00
1bbf31605c fix(monitoring): exclude unconfigured routes from domain activity aggregation 2026-04-13 19:17:46 +00:00
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
0a39d50d20 v13.16.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 18:51:41 +00:00
de7b9f7ec5 fix(deps): bump @push.rocks/smartproxy to ^27.6.0 2026-04-13 18:51:41 +00:00
bd959464c7 v13.16.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 18:08:36 +00:00
36b629676f fix(migrations): use exact smartdata collection names in route unification migration 2026-04-13 18:08:36 +00:00
19398ea836 v13.16.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 17:38:23 +00:00
4aba8cc353 feat(routes): unify route storage and management across config, email, dns, and API origins 2026-04-13 17:38:23 +00:00
5fd036eeb6 v13.15.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 12:15:11 +00:00
cfcb66f1ee fix(monitoring): improve domain activity aggregation for multi-domain and wildcard routes 2026-04-13 12:15:11 +00:00
501f4f9de6 v13.15.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 12:07:13 +00:00
fa926eb10b feat(stats): add typed network stats response fields for bandwidth, domain activity, and protocol distribution 2026-04-13 12:07:13 +00:00
f2d0a9ec1b v13.14.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 11:04:15 +00:00
035173702d feat(network): add bandwidth-ranked IP and domain activity metrics to network monitoring 2026-04-13 11:04:15 +00:00
38 changed files with 1718 additions and 1093 deletions

17
AGENTS.md Normal file
View File

@@ -0,0 +1,17 @@
# Agent Instructions for dcrouter
## Database & Migrations
### Collection Names
smartdata uses the **exact class name** as the MongoDB collection name. No lowercasing.
- `StoredRouteDoc` → collection `StoredRouteDoc`
- `TargetProfileDoc` → collection `TargetProfileDoc`
- `RouteDoc` → collection `RouteDoc`
When writing migrations in `ts_migrations/index.ts`, use the exact class name casing in `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
### Migration Rules
- All DB schema migrations go EXCLUSIVELY in `ts_migrations/index.ts` as smartmigration steps.
- NEVER put migration logic in application code (services, managers, startup hooks).
- Migration step `.to()` version must match the release version so smartmigration can plan the step.
- Steps must be idempotent — smartmigration may re-run them in skip-forward resume mode.

View File

@@ -1,5 +1,105 @@
# Changelog # Changelog
## 2026-04-14 - 13.17.9 - fix(monitoring)
align domain activity metrics with id-keyed route data
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
- Add a regression test covering domain activity aggregation for routes identified only by id.
- Update the network activity UI to show formatted total connection counts in the active connections card.
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
## 2026-04-14 - 13.17.8 - fix(opsserver)
align certificate status handling with the updated smartproxy response format
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
- bump @push.rocks/smartproxy to ^27.7.3
- enable verbose output for the test script
## 2026-04-14 - 13.17.7 - fix(repo)
no changes to commit
## 2026-04-14 - 13.17.6 - fix(dns,routes)
keep DoH socket-handler routes runtime-only and prune stale persisted entries
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
normalize target profile route references and stabilize VPN host-IP client routing behavior
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
sync route filter toggle selection via component changeSubject
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
## 2026-04-13 - 13.17.2 - fix(monitoring)
exclude unconfigured routes from domain activity aggregation
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
## 2026-04-13 - 13.17.1 - fix(monitoring)
stop allocating route metrics to domains when no request data exists
- Removes the equal-split fallback for shared routes in MetricsManager.
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
add request-based domain activity metrics and split routes into user and system views
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
## 2026-04-13 - 13.16.2 - fix(deps)
bump @push.rocks/smartproxy to ^27.6.0
- updates @push.rocks/smartproxy from ^27.5.0 to ^27.6.0 in package.json
## 2026-04-13 - 13.16.1 - fix(migrations)
use exact smartdata collection names in route unification migration
- Update the 13.16.0 migration to rename StoredRouteDoc to RouteDoc using case-sensitive collection names
- Apply the origin backfill against the RouteDoc collection and drop RouteOverrideDoc with matching class-name casing
- Clarify migration description and comments to reflect smartdata's exact class-name collection mapping
## 2026-04-13 - 13.16.0 - feat(routes)
unify route storage and management across config, email, dns, and API origins
- Persist config-, email-, and dns-seeded routes in the database alongside API-created routes using a single RouteDoc model with origin tracking
- Remove hardcoded-route override handling in favor of direct route CRUD and toggle operations by route id across the API client, handlers, and web UI
- Add a migration that renames stored route storage, sets migrated routes to origin="api", and drops obsolete route override data
## 2026-04-13 - 13.15.1 - fix(monitoring)
improve domain activity aggregation for multi-domain and wildcard routes
- map route metrics across all configured domains instead of only the first domain
- resolve wildcard domain patterns against active protocol cache entries
- distribute shared route traffic across matched domains and preserve fallback reporting for routes without domain configuration
## 2026-04-13 - 13.15.0 - feat(stats)
add typed network stats response fields for bandwidth, domain activity, and protocol distribution
- extends the network stats request interface with top IP bandwidth, domain activity, and frontend/backend protocol distribution data
- updates app state to use a typed getNetworkStats request instead of casting the response to any
## 2026-04-13 - 13.14.0 - feat(network)
add bandwidth-ranked IP and domain activity metrics to network monitoring
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
## 2026-04-13 - 13.13.0 - feat(dns) ## 2026-04-13 - 13.13.0 - feat(dns)
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "13.13.0", "version": "13.17.9",
"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)",
@@ -51,10 +51,10 @@
"@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.1",
"@push.rocks/smartnetwork": "^4.5.2", "@push.rocks/smartnetwork": "^4.6.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.5.0", "@push.rocks/smartproxy": "^27.7.4",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
@@ -62,12 +62,12 @@
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.2", "@push.rocks/smartvpn": "1.19.2",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.3", "@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.15.3", "@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0", "@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"lru-cache": "^11.3.3", "lru-cache": "^11.3.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },

67
pnpm-lock.yaml generated
View File

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

View File

@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
match: { ports: 443, domains: 'example.com' }, match: { ports: 443, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] }, action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
}, },
source: 'programmatic', id: 'route-123',
enabled: true, enabled: true,
overridden: false, origin: 'api',
storedRouteId: 'route-123',
createdAt: 1000, createdAt: 1000,
updatedAt: 2000, updatedAt: 2000,
}); });
expect(route.name).toEqual('test-route'); expect(route.name).toEqual('test-route');
expect(route.source).toEqual('programmatic'); expect(route.id).toEqual('route-123');
expect(route.enabled).toEqual(true); expect(route.enabled).toEqual(true);
expect(route.overridden).toEqual(false); expect(route.origin).toEqual('api');
expect(route.storedRouteId).toEqual('route-123');
expect(route.routeConfig.match.ports).toEqual(443); expect(route.routeConfig.match.ports).toEqual(443);
}); });
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const route = new Route(client, {
route: {
name: 'hardcoded-route',
match: { ports: 80 },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
},
source: 'hardcoded',
enabled: true,
overridden: false,
// No storedRouteId for hardcoded routes
});
let updateError: Error | undefined;
try {
await route.update({ name: 'new-name' });
} catch (e) {
updateError = e as Error;
}
expect(updateError).toBeTruthy();
expect(updateError!.message).toInclude('hardcoded');
let deleteError: Error | undefined;
try {
await route.delete();
} catch (e) {
deleteError = e as Error;
}
expect(deleteError).toBeTruthy();
let toggleError: Error | undefined;
try {
await route.toggle(false);
} catch (e) {
toggleError = e as Error;
}
expect(toggleError).toBeTruthy();
});
// ============================================================================= // =============================================================================
// Certificate resource class // Certificate resource class
// ============================================================================= // =============================================================================

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

@@ -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

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

View File

@@ -312,8 +312,11 @@ export class DcRouter {
// TypedRouter for API endpoints // TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager) // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = []; private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Runtime-only DoH routes. These carry live socket handlers and must never be persisted.
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access // Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/'); private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -545,11 +548,12 @@ export class DcRouter {
await this.referenceResolver.initialize(); await this.referenceResolver.initialize();
// Initialize target profile manager // Initialize target profile manager
this.targetProfileManager = new TargetProfileManager(); this.targetProfileManager = new TargetProfileManager(
() => this.routeConfigManager?.getRoutes() || new Map(),
);
await this.targetProfileManager.initialize(); await this.targetProfileManager.initialize();
this.routeConfigManager = new RouteConfigManager( this.routeConfigManager = new RouteConfigManager(
() => this.getConstructorRoutes(),
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
this.options.vpnConfig?.enabled this.options.vpnConfig?.enabled
@@ -559,12 +563,15 @@ export class DcRouter {
return []; return [];
} }
return this.targetProfileManager.getMatchingClientIps( return this.targetProfileManager.getMatchingClientIps(
route, routeId, this.vpnManager.listClients(), route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
); );
} }
: undefined, : undefined,
this.referenceResolver, this.referenceResolver,
// Sync merged 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
(routes) => { (routes) => {
if (this.remoteIngressManager) { if (this.remoteIngressManager) {
@@ -574,10 +581,15 @@ export class DcRouter {
this.tunnelManager.syncAllowedEdges(); this.tunnelManager.syncAllowedEdges();
} }
}, },
() => this.runtimeDnsRoutes,
); );
this.apiTokenManager = new ApiTokenManager(); this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize(); await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize(); await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
await this.targetProfileManager.normalizeAllRouteRefs();
// Seed default profiles/targets if DB is empty and seeding is enabled // Seed default profiles/targets if DB is empty and seeding is enabled
const seeder = new DbSeeder(this.referenceResolver); const seeder = new DbSeeder(this.referenceResolver);
@@ -881,31 +893,30 @@ export class DcRouter {
this.smartProxy = undefined; this.smartProxy = undefined;
} }
let routes: plugins.smartproxy.IRouteConfig[] = []; // 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`);
// If user provides full SmartProxy config, use its routes. this.seedEmailRoutes = [];
// NOTE: `smartProxyConfig.acme` is now seed-only — consumed by
// AcmeConfigManager on first boot. The live ACME config always comes
// from the DB via `this.acmeConfigManager.getConfig()`.
if (this.options.smartProxyConfig) {
routes = this.options.smartProxyConfig.routes || [];
logger.log('info', `Found ${routes.length} routes in config`);
}
// If email config exists, automatically add email routes
if (this.options.emailConfig) { if (this.options.emailConfig) {
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig); this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) }); logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
} }
// If DNS is configured, add DNS routes this.runtimeDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
const dnsRoutes = this.generateDnsRoutes(); this.runtimeDnsRoutes = this.generateDnsRoutes();
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) }); logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) });
routes = [...routes, ...dnsRoutes];
} }
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
let routes: plugins.smartproxy.IRouteConfig[] = [
...this.seedConfigRoutes,
...this.seedEmailRoutes,
...this.runtimeDnsRoutes,
];
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager. // Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
// If no config exists or it's disabled, SmartProxy's own ACME is turned off // If no config exists or it's disabled, SmartProxy's own ACME is turned off
// and dcrouter's SmartAcme / certProvisionFunction are not wired. // and dcrouter's SmartAcme / certProvisionFunction are not wired.
@@ -952,10 +963,6 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
} }
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
this.constructorRoutes = [...routes];
// If we have routes or need a basic SmartProxy instance, create it // If we have routes or need a basic SmartProxy instance, create it
if (routes.length > 0 || this.options.smartProxyConfig) { if (routes.length > 0 || this.options.smartProxyConfig) {
logger.log('info', 'Setting up SmartProxy with combined configuration'); logger.log('info', 'Setting up SmartProxy with combined configuration');
@@ -1406,14 +1413,6 @@ export class DcRouter {
return names; return names;
} }
/**
* Get the routes derived from constructor config (smartProxy + email + DNS).
* Used by RouteConfigManager as the "hardcoded" base.
*/
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
return this.constructorRoutes;
}
public async stop() { public async stop() {
logger.log('info', 'Stopping DcRouter services...'); logger.log('info', 'Stopping DcRouter services...');
@@ -1457,17 +1456,15 @@ export class DcRouter {
// Update configuration // Update configuration
this.options.smartProxyConfig = config; this.options.smartProxyConfig = config;
// Update routes on RemoteIngressManager so derived ports stay in sync // Start new SmartProxy with updated configuration (rebuilds seed routes)
if (this.remoteIngressManager && config.routes) {
this.remoteIngressManager.setRoutes(config.routes as any[]);
}
// Start new SmartProxy with updated configuration (will include email routes if configured)
await this.setupSmartProxy(); await this.setupSmartProxy();
// Re-apply programmatic routes and overrides after SmartProxy restart // Re-seed and re-apply all routes after SmartProxy restart
if (this.routeConfigManager) { if (this.routeConfigManager) {
await this.routeConfigManager.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[],
);
} }
logger.log('info', 'SmartProxy configuration updated'); logger.log('info', 'SmartProxy configuration updated');
@@ -2185,13 +2182,14 @@ export class DcRouter {
this.remoteIngressManager = new RemoteIngressManager(); this.remoteIngressManager = new RemoteIngressManager();
await this.remoteIngressManager.initialize(); await this.remoteIngressManager.initialize();
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes // Pass current bootstrap routes so the manager can derive edge ports initially.
const currentRoutes = this.constructorRoutes; // Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
this.remoteIngressManager.setRoutes(currentRoutes as any[]); // will push the complete merged routes here.
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
// Race-condition fix: if ConfigManagers finished before us, re-apply routes // If ConfigManagers finished before us, re-apply routes
// so the callback delivers the full merged set (including DB-stored routes) // so the callback delivers the full DB set to our newly-created remoteIngressManager.
// to our newly-created remoteIngressManager.
if (this.routeConfigManager) { if (this.routeConfigManager) {
await this.routeConfigManager.applyRoutes(); await this.routeConfigManager.applyRoutes();
} }
@@ -2278,11 +2276,10 @@ export class DcRouter {
if (!this.targetProfileManager) return [...ips]; if (!this.targetProfileManager) return [...ips];
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[]; const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec( const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, routes, storedRoutes, targetProfileIds, allRoutes,
); );
// Add target IPs directly // Add target IPs directly
@@ -2292,8 +2289,11 @@ export class DcRouter {
// Resolve DNS A records for matched domains (with caching) // Resolve DNS A records for matched domains (with caching)
for (const domain of domains) { for (const domain of domains) {
const stripped = domain.replace(/^\*\./, ''); if (this.isWildcardVpnDomain(domain)) {
const resolvedIps = await this.resolveVpnDomainIPs(stripped); this.logSkippedWildcardAllowedIp(domain);
continue;
}
const resolvedIps = await this.resolveVpnDomainIPs(domain);
for (const ip of resolvedIps) { for (const ip of resolvedIps) {
ips.add(`${ip}/32`); ips.add(`${ip}/32`);
} }
@@ -2305,14 +2305,15 @@ export class DcRouter {
await this.vpnManager.start(); await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes // Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since // get correct profile-based ipAllowLists
// VPN server wasn't ready yet)
await this.routeConfigManager?.applyRoutes(); await this.routeConfigManager?.applyRoutes();
} }
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */ /** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>(); 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. * Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
@@ -2338,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() // VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
// via the getVpnAllowList callback — no longer a separate method here. // via the getVpnAllowList callback — no longer a separate method here.

View File

@@ -1,11 +1,11 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js'; import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
import type { import type {
ISourceProfile, ISourceProfile,
INetworkTarget, INetworkTarget,
IRouteMetadata, IRouteMetadata,
IStoredRoute, IRoute,
IRouteSecurity, IRouteSecurity,
} from '../../ts_interfaces/data/route-management.js'; } from '../../ts_interfaces/data/route-management.js';
@@ -81,7 +81,7 @@ export class ReferenceResolver {
public async deleteProfile( public async deleteProfile(
id: string, id: string,
force: boolean, force: boolean,
storedRoutes?: Map<string, IStoredRoute>, storedRoutes?: Map<string, IRoute>,
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id); const profile = this.profiles.get(id);
if (!profile) { if (!profile) {
@@ -131,7 +131,7 @@ export class ReferenceResolver {
return [...this.profiles.values()]; return [...this.profiles.values()];
} }
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> { public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>(); const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) { for (const profile of this.profiles.values()) {
usage.set(profile.id, []); usage.set(profile.id, []);
@@ -147,7 +147,7 @@ export class ReferenceResolver {
public getProfileUsageForId( public getProfileUsageForId(
profileId: string, profileId: string,
storedRoutes: Map<string, IStoredRoute>, storedRoutes: Map<string, IRoute>,
): Array<{ id: string; routeName: string }> { ): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = []; const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
@@ -214,7 +214,7 @@ export class ReferenceResolver {
public async deleteTarget( public async deleteTarget(
id: string, id: string,
force: boolean, force: boolean,
storedRoutes?: Map<string, IStoredRoute>, storedRoutes?: Map<string, IRoute>,
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
const target = this.targets.get(id); const target = this.targets.get(id);
if (!target) { if (!target) {
@@ -263,7 +263,7 @@ export class ReferenceResolver {
public getTargetUsageForId( public getTargetUsageForId(
targetId: string, targetId: string,
storedRoutes: Map<string, IStoredRoute>, storedRoutes: Map<string, IRoute>,
): Array<{ id: string; routeName: string }> { ): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = []; const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
@@ -334,20 +334,20 @@ export class ReferenceResolver {
// ========================================================================= // =========================================================================
public async findRoutesByProfileRef(profileId: string): Promise<string[]> { public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
return docs return docs
.filter((doc) => doc.metadata?.sourceProfileRef === profileId) .filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.map((doc) => doc.id); .map((doc) => doc.id);
} }
public async findRoutesByTargetRef(targetId: string): Promise<string[]> { public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
return docs return docs
.filter((doc) => doc.metadata?.networkTargetRef === targetId) .filter((doc) => doc.metadata?.networkTargetRef === targetId)
.map((doc) => doc.id); .map((doc) => doc.id);
} }
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] { public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = []; const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) { if (stored.metadata?.sourceProfileRef === profileId) {
@@ -357,7 +357,7 @@ export class ReferenceResolver {
return ids; return ids;
} }
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] { public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = []; const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.networkTargetRef === targetId) { if (stored.metadata?.networkTargetRef === targetId) {
@@ -547,7 +547,7 @@ export class ReferenceResolver {
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> { private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) { for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId); const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) { if (doc?.metadata) {
doc.metadata = { doc.metadata = {
...doc.metadata, ...doc.metadata,
@@ -562,7 +562,7 @@ export class ReferenceResolver {
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> { private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) { for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId); const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) { if (doc?.metadata) {
doc.metadata = { doc.metadata = {
...doc.metadata, ...doc.metadata,

View File

@@ -1,9 +1,8 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js'; import { RouteDoc } from '../db/index.js';
import type { import type {
IStoredRoute, IRoute,
IRouteOverride,
IMergedRoute, IMergedRoute,
IRouteWarning, IRouteWarning,
IRouteMetadata, IRouteMetadata,
@@ -46,66 +45,58 @@ class RouteUpdateMutex {
} }
export class RouteConfigManager { export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>(); private routes = new Map<string, IRoute>();
private overrides = new Map<string, IRouteOverride>();
private warnings: IRouteWarning[] = []; private warnings: IRouteWarning[] = [];
private routeUpdateMutex = new RouteUpdateMutex(); private routeUpdateMutex = new RouteUpdateMutex();
constructor( constructor(
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined, private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[], private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private referenceResolver?: ReferenceResolver, private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
) {} ) {}
/** Expose stored routes map for reference resolution lookups. */ /** Expose routes map for reference resolution lookups. */
public getStoredRoutes(): Map<string, IStoredRoute> { public getRoutes(): Map<string, IRoute> {
return this.storedRoutes; return this.routes;
} }
/** /**
* Load persisted routes and overrides, 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(): Promise<void> { public async initialize(
await this.loadStoredRoutes(); configRoutes: IDcRouterRouteConfig[] = [],
await this.loadOverrides(); emailRoutes: IDcRouterRouteConfig[] = [],
dnsRoutes: IDcRouterRouteConfig[] = [],
): Promise<void> {
await this.loadRoutes();
await this.seedRoutes(configRoutes, 'config');
await this.seedRoutes(emailRoutes, 'email');
await this.seedRoutes(dnsRoutes, 'dns');
this.computeWarnings(); this.computeWarnings();
this.logWarnings(); this.logWarnings();
await this.applyRoutes(); await this.applyRoutes();
} }
// ========================================================================= // =========================================================================
// Merged view // Route listing
// ========================================================================= // =========================================================================
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } { public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
const merged: IMergedRoute[] = []; const merged: IMergedRoute[] = [];
// Hardcoded routes for (const route of this.routes.values()) {
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
merged.push({ merged.push({
route, route: route.route,
source: 'hardcoded', id: route.id,
enabled: override ? override.enabled : true, enabled: route.enabled,
overridden: !!override, origin: route.origin,
}); createdAt: route.createdAt,
} updatedAt: route.updatedAt,
metadata: route.metadata,
// Programmatic routes
for (const stored of this.storedRoutes.values()) {
merged.push({
route: stored.route,
source: 'programmatic',
enabled: stored.enabled,
overridden: false,
storedRouteId: stored.id,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
metadata: stored.metadata,
}); });
} }
@@ -113,7 +104,7 @@ export class RouteConfigManager {
} }
// ========================================================================= // =========================================================================
// Programmatic route CRUD // Route CRUD
// ========================================================================= // =========================================================================
public async createRoute( public async createRoute(
@@ -127,7 +118,7 @@ export class RouteConfigManager {
// Ensure route has a name // Ensure route has a name
if (!route.name) { if (!route.name) {
route.name = `programmatic-${id.slice(0, 8)}`; route.name = `route-${id.slice(0, 8)}`;
} }
// Resolve references if metadata has refs and resolver is available // Resolve references if metadata has refs and resolver is available
@@ -138,17 +129,18 @@ export class RouteConfigManager {
resolvedMetadata = resolved.metadata; resolvedMetadata = resolved.metadata;
} }
const stored: IStoredRoute = { const stored: IRoute = {
id, id,
route, route,
enabled, enabled,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy, createdBy,
origin: 'api',
metadata: resolvedMetadata, metadata: resolvedMetadata,
}; };
this.storedRoutes.set(id, stored); this.routes.set(id, stored);
await this.persistRoute(stored); await this.persistRoute(stored);
await this.applyRoutes(); await this.applyRoutes();
return id; return id;
@@ -162,7 +154,7 @@ export class RouteConfigManager {
metadata?: Partial<IRouteMetadata>; metadata?: Partial<IRouteMetadata>;
}, },
): Promise<boolean> { ): Promise<boolean> {
const stored = this.storedRoutes.get(id); const stored = this.routes.get(id);
if (!stored) return false; if (!stored) return false;
if (patch.route) { if (patch.route) {
@@ -201,9 +193,9 @@ export class RouteConfigManager {
} }
public async deleteRoute(id: string): Promise<boolean> { public async deleteRoute(id: string): Promise<boolean> {
if (!this.storedRoutes.has(id)) return false; if (!this.routes.has(id)) return false;
this.storedRoutes.delete(id); this.routes.delete(id);
const doc = await StoredRouteDoc.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 true;
@@ -214,103 +206,141 @@ export class RouteConfigManager {
} }
// ========================================================================= // =========================================================================
// Hardcoded route overrides // Private: seed routes from constructor config
// ========================================================================= // =========================================================================
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> { /**
const override: IRouteOverride = { * Upsert seed routes by name+origin. Preserves user's `enabled` state.
routeName, * Deletes stale DB routes whose origin matches but name is not in the seed set.
enabled, */
updatedAt: Date.now(), private async seedRoutes(
updatedBy, seedRoutes: IDcRouterRouteConfig[],
}; origin: 'config' | 'email' | 'dns',
this.overrides.set(routeName, override); ): Promise<void> {
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName); if (seedRoutes.length === 0) return;
if (existingDoc) {
existingDoc.enabled = override.enabled;
existingDoc.updatedAt = override.updatedAt;
existingDoc.updatedBy = override.updatedBy;
await existingDoc.save();
} else {
const doc = new RouteOverrideDoc();
doc.routeName = override.routeName;
doc.enabled = override.enabled;
doc.updatedAt = override.updatedAt;
doc.updatedBy = override.updatedBy;
await doc.save();
}
this.computeWarnings();
await this.applyRoutes();
}
public async removeOverride(routeName: string): Promise<boolean> { const seedNames = new Set<string>();
if (!this.overrides.has(routeName)) return false; let seeded = 0;
this.overrides.delete(routeName); let updated = 0;
const doc = await RouteOverrideDoc.findByRouteName(routeName);
if (doc) await doc.delete(); for (const route of seedRoutes) {
this.computeWarnings(); const name = route.name || '';
await this.applyRoutes(); seedNames.add(name);
return true;
// Check if a route with this name+origin already exists in memory
let existingId: string | undefined;
for (const [id, r] of this.routes) {
if (r.origin === origin && r.route.name === name) {
existingId = id;
break;
}
}
if (existingId) {
// Update route config but preserve enabled state
const existing = this.routes.get(existingId)!;
existing.route = route;
existing.updatedAt = Date.now();
await this.persistRoute(existing);
updated++;
} else {
// Insert new seed route
const id = plugins.uuid.v4();
const now = Date.now();
const newRoute: IRoute = {
id,
route,
enabled: true,
createdAt: now,
updatedAt: now,
createdBy: 'system',
origin,
};
this.routes.set(id, newRoute);
await this.persistRoute(newRoute);
seeded++;
}
}
// Delete stale routes: same origin but name not in current seed set
const staleIds: string[] = [];
for (const [id, r] of this.routes) {
if (r.origin === origin && !seedNames.has(r.route.name || '')) {
staleIds.push(id);
}
}
for (const id of staleIds) {
this.routes.delete(id);
const doc = await RouteDoc.findById(id);
if (doc) await doc.delete();
}
if (seeded > 0 || updated > 0 || staleIds.length > 0) {
logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`);
}
} }
// ========================================================================= // =========================================================================
// Private: persistence // Private: persistence
// ========================================================================= // =========================================================================
private async loadStoredRoutes(): Promise<void> { private async loadRoutes(): Promise<void> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
let prunedRuntimeRoutes = 0;
for (const doc of docs) { for (const doc of docs) {
if (doc.id) { if (!doc.id) continue;
this.storedRoutes.set(doc.id, {
id: doc.id, const storedRoute: IRoute = {
route: doc.route, id: doc.id,
enabled: doc.enabled, route: doc.route,
createdAt: doc.createdAt, enabled: doc.enabled,
updatedAt: doc.updatedAt, createdAt: doc.createdAt,
createdBy: doc.createdBy, updatedAt: doc.updatedAt,
metadata: doc.metadata, 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.storedRoutes.size > 0) { if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`); 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 loadOverrides(): Promise<void> { private async persistRoute(stored: IRoute): Promise<void> {
const docs = await RouteOverrideDoc.findAll(); const existingDoc = await RouteDoc.findById(stored.id);
for (const doc of docs) {
if (doc.routeName) {
this.overrides.set(doc.routeName, {
routeName: doc.routeName,
enabled: doc.enabled,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
});
}
}
if (this.overrides.size > 0) {
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
}
}
private async persistRoute(stored: IStoredRoute): Promise<void> {
const existingDoc = await StoredRouteDoc.findById(stored.id);
if (existingDoc) { if (existingDoc) {
existingDoc.route = stored.route; existingDoc.route = stored.route;
existingDoc.enabled = stored.enabled; existingDoc.enabled = stored.enabled;
existingDoc.updatedAt = stored.updatedAt; existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy; existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin;
existingDoc.metadata = stored.metadata; existingDoc.metadata = stored.metadata;
await existingDoc.save(); await existingDoc.save();
} else { } else {
const doc = new StoredRouteDoc(); const doc = new RouteDoc();
doc.id = stored.id; doc.id = stored.id;
doc.route = stored.route; doc.route = stored.route;
doc.enabled = stored.enabled; doc.enabled = stored.enabled;
doc.createdAt = stored.createdAt; doc.createdAt = stored.createdAt;
doc.updatedAt = stored.updatedAt; doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy; doc.createdBy = stored.createdBy;
doc.origin = stored.origin;
doc.metadata = stored.metadata; doc.metadata = stored.metadata;
await doc.save(); await doc.save();
} }
@@ -322,33 +352,14 @@ export class RouteConfigManager {
private computeWarnings(): void { private computeWarnings(): void {
this.warnings = []; this.warnings = [];
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
// Check overrides for (const route of this.routes.values()) {
for (const [routeName, override] of this.overrides) { if (!route.enabled) {
if (!hardcodedNames.has(routeName)) { const name = route.route.name || route.id;
this.warnings.push({ this.warnings.push({
type: 'orphaned-override', type: 'disabled-route',
routeName,
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
});
} else if (!override.enabled) {
this.warnings.push({
type: 'disabled-hardcoded',
routeName,
message: `Route '${routeName}' is disabled via API override`,
});
}
}
// Check disabled programmatic routes
for (const stored of this.storedRoutes.values()) {
if (!stored.enabled) {
const name = stored.route.name || stored.id;
this.warnings.push({
type: 'disabled-programmatic',
routeName: name, routeName: name,
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`, message: `Route '${name}' (id: ${route.id}) is disabled`,
}); });
} }
} }
@@ -372,7 +383,7 @@ export class RouteConfigManager {
if (!this.referenceResolver || routeIds.length === 0) return; if (!this.referenceResolver || routeIds.length === 0) return;
for (const routeId of routeIds) { for (const routeId of routeIds) {
const stored = this.storedRoutes.get(routeId); const stored = this.routes.get(routeId);
if (!stored?.metadata) continue; if (!stored?.metadata) continue;
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
@@ -387,7 +398,7 @@ export class RouteConfigManager {
} }
// ========================================================================= // =========================================================================
// Private: apply merged routes to SmartProxy // Apply routes to SmartProxy
// ========================================================================= // =========================================================================
public async applyRoutes(): Promise<void> { public async applyRoutes(): Promise<void> {
@@ -397,54 +408,69 @@ export class RouteConfigManager {
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const http3Config = this.getHttp3Config?.(); // Add all enabled routes with HTTP/3 and VPN augmentation
const vpnCallback = this.getVpnClientIpsForRoute; for (const route of this.routes.values()) {
if (route.enabled) {
// Helper: inject VPN security into a vpnOnly route enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
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 enabled hardcoded routes (respecting overrides, with fresh VPN injection)
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
if (override && !override.enabled) {
continue; // Skip disabled hardcoded route
} }
enabledRoutes.push(injectVpn(route));
} }
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation) const runtimeRoutes = this.getRuntimeRoutes?.() || [];
for (const stored of this.storedRoutes.values()) { for (const route of runtimeRoutes) {
if (stored.enabled) { enabledRoutes.push(this.prepareRouteForApply(route));
let route = stored.route;
if (http3Config?.enabled !== false) {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
}
enabledRoutes.push(injectVpn(route, stored.id));
}
} }
await smartProxy.updateRoutes(enabledRoutes); await smartProxy.updateRoutes(enabledRoutes);
// Notify listeners (e.g. RemoteIngressManager) of the merged route set // Notify listeners (e.g. RemoteIngressManager) of the route set
if (this.onRoutesApplied) { if (this.onRoutesApplied) {
this.onRoutesApplied(enabledRoutes); this.onRoutesApplied(enabledRoutes);
} }
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`); 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

@@ -3,7 +3,7 @@ import { logger } from '../logger.js';
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js'; import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js'; import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'; import type { IRoute } from '../../ts_interfaces/data/route-management.js';
/** /**
* Manages TargetProfiles (target-side: what can be accessed). * Manages TargetProfiles (target-side: what can be accessed).
@@ -13,6 +13,10 @@ import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'
export class TargetProfileManager { export class TargetProfileManager {
private profiles = new Map<string, ITargetProfile>(); private profiles = new Map<string, ITargetProfile>();
constructor(
private getAllRoutes?: () => Map<string, IRoute>,
) {}
// ========================================================================= // =========================================================================
// Lifecycle // Lifecycle
// ========================================================================= // =========================================================================
@@ -43,13 +47,14 @@ export class TargetProfileManager {
const id = plugins.uuid.v4(); const id = plugins.uuid.v4();
const now = Date.now(); const now = Date.now();
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
const profile: ITargetProfile = { const profile: ITargetProfile = {
id, id,
name: data.name, name: data.name,
description: data.description, description: data.description,
domains: data.domains, domains: data.domains,
targets: data.targets, targets: data.targets,
routeRefs: data.routeRefs, routeRefs,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy: data.createdBy, createdBy: data.createdBy,
@@ -70,11 +75,19 @@ export class TargetProfileManager {
throw new Error(`Target profile '${id}' not found`); 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.name !== undefined) profile.name = patch.name;
if (patch.description !== undefined) profile.description = patch.description; if (patch.description !== undefined) profile.description = patch.description;
if (patch.domains !== undefined) profile.domains = patch.domains; if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets; 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(); profile.updatedAt = Date.now();
await this.persistProfile(profile); await this.persistProfile(profile);
@@ -127,6 +140,29 @@ export class TargetProfileManager {
return this.profiles.get(id); 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[] { public listProfiles(): ITargetProfile[] {
return [...this.profiles.values()]; return [...this.profiles.values()];
} }
@@ -178,9 +214,11 @@ export class TargetProfileManager {
route: IDcRouterRouteConfig, route: IDcRouterRouteConfig,
routeId: string | undefined, routeId: string | undefined,
clients: VpnClientDoc[], clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(),
): Array<string | { ip: string; domains: string[] }> { ): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = []; const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || []; const routeDomains: string[] = (route.match as any)?.domains || [];
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) { for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue; if (!client.enabled || !client.assignedIp) continue;
@@ -194,7 +232,13 @@ export class TargetProfileManager {
const profile = this.profiles.get(profileId); const profile = this.profiles.get(profileId);
if (!profile) continue; if (!profile) continue;
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains); const matchResult = this.routeMatchesProfileDetailed(
route,
routeId,
profile,
routeDomains,
routeNameIndex,
);
if (matchResult === 'full') { if (matchResult === 'full') {
fullAccess = true; fullAccess = true;
break; // No need to check more profiles break; // No need to check more profiles
@@ -220,11 +264,11 @@ export class TargetProfileManager {
*/ */
public getClientAccessSpec( public getClientAccessSpec(
targetProfileIds: string[], targetProfileIds: string[],
allRoutes: IDcRouterRouteConfig[], allRoutes: Map<string, IRoute>,
storedRoutes: Map<string, IStoredRoute>,
): { domains: string[]; targetIps: string[] } { ): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>(); const domains = new Set<string>();
const targetIps = new Set<string>(); const targetIps = new Set<string>();
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
// Collect all access specifiers from assigned profiles // Collect all access specifiers from assigned profiles
for (const profileId of targetProfileIds) { for (const profileId of targetProfileIds) {
@@ -245,23 +289,16 @@ export class TargetProfileManager {
} }
} }
// Route references: scan constructor routes // Route references: scan all routes
for (const route of allRoutes) { for (const [routeId, route] of allRoutes) {
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) { if (!route.enabled) continue;
const routeDomains = (route.match as any)?.domains; if (this.routeMatchesProfile(
if (Array.isArray(routeDomains)) { route.route as IDcRouterRouteConfig,
for (const d of routeDomains) { routeId,
domains.add(d); profile,
} routeNameIndex,
} )) {
} const routeDomains = (route.route.match as any)?.domains;
}
// Route references: scan stored routes
for (const [storedId, stored] of storedRoutes) {
if (!stored.enabled) continue;
if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) {
const routeDomains = (stored.route.match as any)?.domains;
if (Array.isArray(routeDomains)) { if (Array.isArray(routeDomains)) {
for (const d of routeDomains) { for (const d of routeDomains) {
domains.add(d); domains.add(d);
@@ -288,9 +325,16 @@ export class TargetProfileManager {
route: IDcRouterRouteConfig, route: IDcRouterRouteConfig,
routeId: string | undefined, routeId: string | undefined,
profile: ITargetProfile, profile: ITargetProfile,
routeNameIndex: Map<string, string[]>,
): boolean { ): boolean {
const routeDomains: string[] = (route.match as any)?.domains || []; 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'; return result !== 'none';
} }
@@ -307,11 +351,17 @@ export class TargetProfileManager {
routeId: string | undefined, routeId: string | undefined,
profile: ITargetProfile, profile: ITargetProfile,
routeDomains: string[], routeDomains: string[],
routeNameIndex: Map<string, string[]>,
): 'full' | { type: 'scoped'; domains: string[] } | 'none' { ): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
// 1. Route reference match → full access // 1. Route reference match → full access
if (profile.routeRefs?.length) { if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return 'full'; 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 // 2. Domain match
@@ -375,6 +425,66 @@ export class TargetProfileManager {
return false; 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 // Private: persistence
// ========================================================================= // =========================================================================

View File

@@ -1,32 +0,0 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public routeName!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public updatedBy!: string;
constructor() {
super();
}
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
return await RouteOverrideDoc.getInstance({ routeName });
}
public static async findAll(): Promise<RouteOverrideDoc[]> {
return await RouteOverrideDoc.getInstances({});
}
}

View File

@@ -6,7 +6,7 @@ import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteing
const getDb = () => DcRouterDb.getInstance().getDb(); const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb()) @plugins.smartdata.Collection(() => getDb())
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> { export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
@plugins.smartdata.unI() @plugins.smartdata.unI()
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public id!: string; public id!: string;
@@ -26,6 +26,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public createdBy!: string; public createdBy!: string;
@plugins.smartdata.svDb()
public origin!: 'config' | 'email' | 'dns' | 'api';
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public metadata?: IRouteMetadata; public metadata?: IRouteMetadata;
@@ -33,11 +36,19 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
super(); super();
} }
public static async findById(id: string): Promise<StoredRouteDoc | null> { public static async findById(id: string): Promise<RouteDoc | null> {
return await StoredRouteDoc.getInstance({ id }); return await RouteDoc.getInstance({ id });
} }
public static async findAll(): Promise<StoredRouteDoc[]> { public static async findAll(): Promise<RouteDoc[]> {
return await StoredRouteDoc.getInstances({}); return await RouteDoc.getInstances({});
}
public static async findByName(name: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ 'route.name': name });
}
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
return await RouteDoc.getInstances({ origin });
} }
} }

View File

@@ -3,8 +3,7 @@ export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js'; export * from './classes.cached.ip.reputation.js';
// Config document classes // Config document classes
export * from './classes.stored-route.doc.js'; export * from './classes.route.doc.js';
export * from './classes.route-override.doc.js';
export * from './classes.api-token.doc.js'; export * from './classes.api-token.doc.js';
export * from './classes.source-profile.doc.js'; export * from './classes.source-profile.doc.js';
export * from './classes.target-profile.doc.js'; export * from './classes.target-profile.doc.js';

View File

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

View File

@@ -553,12 +553,14 @@ export class MetricsManager {
connectionsByIP: new Map<string, number>(), connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>, topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>, throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(), throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [] as Array<any>, backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>,
}; };
} }
@@ -572,7 +574,7 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out bytesOutPerSecond: instantThroughput.out
}; };
// Get top IPs // Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10); const topIPs = proxyMetrics.connections.topIPs(10);
// Get total data transferred // Get total data transferred
@@ -699,10 +701,141 @@ export class MetricsManager {
} }
} }
// Build top 10 IPs by bandwidth (sorted by total throughput desc)
const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
for (const [ip, count] of connectionsByIP) {
allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
}
for (const [ip, tp] of throughputByIP) {
const existing = allIPData.get(ip);
if (existing) {
existing.bwIn = tp.in;
existing.bwOut = tp.out;
} else {
allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
}
}
const topIPsByBandwidth = Array.from(allIPData.entries())
.sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// 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 canonical route key → domains from route config
const routeDomains = new Map<string, string[]>();
if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
const routeKey = route.name || route.id;
if (!routeKey || !route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (domains.length > 0) {
routeDomains.set(routeKey, domains);
}
}
}
// Resolve wildcards using domains seen in request metrics
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
for (const entry of protocolCache) {
if (entry.domain) allKnownDomains.add(entry.domain);
}
// Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>();
for (const [routeKey, domains] of routeDomains) {
for (const pattern of domains) {
if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const knownDomain of allKnownDomains) {
if (regex.test(knownDomain)) {
const existing = domainToRoutes.get(knownDomain);
if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(knownDomain, [routeKey]); }
}
}
} else {
const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeKey); }
else { domainToRoutes.set(pattern, [routeKey]); }
}
}
}
// 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, routeKeys] of domainToRoutes) {
const reqs = domainRequestTotals.get(domain) || 0;
for (const routeKey of routeKeys) {
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 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;
}>();
for (const [domain, routeKeys] of domainToRoutes) {
const domainReqs = domainRequestTotals.get(domain) || 0;
let totalConns = 0;
let totalIn = 0;
let totalOut = 0;
for (const routeKey of routeKeys) {
const conns = connectionsByRoute.get(routeKey) || 0;
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
const routeTotal = routeTotalRequests.get(routeKey) || 0;
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
totalConns += conns * share;
totalIn += tp.in * share;
totalOut += tp.out * share;
}
domainAgg.set(domain, {
activeConnections: Math.round(totalConns),
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeKeys.length,
requestCount: domainReqs,
});
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
bytesInPerSecond: data.bytesInPerSec,
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
requestCount: data.requestCount,
}))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
return { return {
connectionsByIP, connectionsByIP,
throughputRate, throughputRate,
topIPs, topIPs,
topIPsByBandwidth,
totalDataTransferred, totalDataTransferred,
throughputHistory, throughputHistory,
throughputByIP, throughputByIP,
@@ -711,6 +844,7 @@ export class MetricsManager {
backends, backends,
frontendProtocols, frontendProtocols,
backendProtocols, backendProtocols,
domainActivity,
}; };
}, 1000); // 1s cache — matches typical dashboard poll interval }, 1000); // 1s cache — matches typical dashboard poll interval
} }

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

@@ -135,7 +135,7 @@ export class NetworkTargetHandler {
const result = await resolver.deleteTarget( const result = await resolver.deleteTarget(
dataArg.id, dataArg.id,
dataArg.force ?? false, dataArg.force ?? false,
manager.getStoredRoutes(), manager.getRoutes(),
); );
if (result.success && dataArg.force) { if (result.success && dataArg.force) {
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
if (!resolver || !manager) { if (!resolver || !manager) {
return { routes: [] }; return { routes: [] };
} }
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes()); const usage = resolver.getTargetUsageForId(dataArg.id, manager.getRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) }; return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
}, },
), ),

View File

@@ -72,7 +72,7 @@ export class RouteManagementHandler {
return { success: false, message: 'Route management not initialized' }; return { success: false, message: 'Route management not initialized' };
} }
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata); const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
return { success: true, storedRouteId: id }; return { success: true, routeId: id };
}, },
), ),
); );
@@ -113,39 +113,7 @@ export class RouteManagementHandler {
), ),
); );
// Set override on a hardcoded route // Toggle route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
return { success: true };
},
),
);
// Remove override from a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.removeOverride(dataArg.routeName);
return { success: ok, message: ok ? undefined : 'Override not found' };
},
),
);
// Toggle programmatic route
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute', 'toggleRoute',

View File

@@ -51,8 +51,8 @@ export class SecurityHandler {
startTime: conn.startTime, startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any, protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any, state: conn.status as any,
bytesReceived: Math.floor(conn.bytesTransferred / 2), bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: Math.floor(conn.bytesTransferred / 2), bytesSent: (conn as any)._throughputOut || 0,
})); }));
const summary = { const summary = {
@@ -96,9 +96,11 @@ export class SecurityHandler {
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })), connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
throughputRate: networkStats.throughputRate, throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs, topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
totalDataTransferred: networkStats.totalDataTransferred, totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [], throughputHistory: networkStats.throughputHistory || [],
throughputByIP, throughputByIP,
domainActivity: networkStats.domainActivity || [],
requestsPerSecond: networkStats.requestsPerSecond || 0, requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0, requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [], backends: networkStats.backends || [],
@@ -110,9 +112,11 @@ export class SecurityHandler {
connectionsByIP: [], connectionsByIP: [],
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [], topIPs: [],
topIPsByBandwidth: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [], throughputHistory: [],
throughputByIP: [], throughputByIP: [],
domainActivity: [],
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [], backends: [],
@@ -251,31 +255,31 @@ export class SecurityHandler {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo(); const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats(); const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Use IP-based connection data from the new metrics API // One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) { if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0; let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server'; const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
for (const [ip, count] of networkStats.connectionsByIP) { for (const [ip, count] of networkStats.connectionsByIP) {
// Create a connection entry for each active IP connection const tp = networkStats.throughputByIP?.get(ip);
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance connections.push({
connections.push({ id: `ip-${connIndex++}`,
id: `conn-${connIndex++}`, type: 'http',
type: 'http', source: {
source: { ip: ip,
ip: ip, port: 0,
port: Math.floor(Math.random() * 50000) + 10000, // High port range },
}, destination: {
destination: { ip: publicIp,
ip: publicIp, port: 443,
port: 443, service: 'proxy',
service: 'proxy', },
}, startTime: 0,
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour bytesTransferred: count, // Store connection count here
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size), status: 'active',
status: 'active', // Attach real throughput for the handler mapping
}); ...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} } as any);
} }
} else if (connectionInfo.length > 0) { } else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available // Fallback to route-based connection info if no IP data available

View File

@@ -136,7 +136,7 @@ export class SourceProfileHandler {
const result = await resolver.deleteProfile( const result = await resolver.deleteProfile(
dataArg.id, dataArg.id,
dataArg.force ?? false, dataArg.force ?? false,
manager.getStoredRoutes(), manager.getRoutes(),
); );
// If force-deleted with affected routes, re-apply // If force-deleted with affected routes, re-apply
@@ -160,7 +160,7 @@ export class SourceProfileHandler {
if (!resolver || !manager) { if (!resolver || !manager) {
return { routes: [] }; return { routes: [] };
} }
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes()); const usage = resolver.getProfileUsageForId(dataArg.id, manager.getRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) }; return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
}, },
), ),

View File

@@ -291,6 +291,20 @@ export class StatsHandler {
} }
} }
// Build connectionDetails from real per-IP data
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
for (const [ip, count] of stats.connectionsByIP) {
const tp = stats.throughputByIP?.get(ip);
connectionDetails.push({
remoteAddress: ip,
protocol: 'https',
state: 'connected',
startTime: 0,
bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0,
});
}
metrics.network = { metrics.network = {
totalBandwidth: { totalBandwidth: {
in: stats.throughputRate.bytesInPerSecond, in: stats.throughputRate.bytesInPerSecond,
@@ -301,12 +315,18 @@ export class StatsHandler {
out: stats.totalDataTransferred.bytesOut, out: stats.totalDataTransferred.bytesOut,
}, },
activeConnections: serverStats.activeConnections, activeConnections: serverStats.activeConnections,
connectionDetails: [], connectionDetails,
topEndpoints: stats.topIPs.map(ip => ({ topEndpoints: stats.topIPs.map(ip => ({
endpoint: ip.ip, endpoint: ip.ip,
requests: ip.count, connections: ip.count,
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 }, bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
})), })),
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
endpoint: ip.ip,
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [], throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0, requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0, requestsTotal: stats.requestsTotal || 0,

View File

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

View File

@@ -7,10 +7,9 @@ export class Route {
// Data from IMergedRoute // Data from IMergedRoute
public routeConfig: IRouteConfig; public routeConfig: IRouteConfig;
public source: 'hardcoded' | 'programmatic'; public id: string;
public enabled: boolean; public enabled: boolean;
public overridden: boolean; public origin: 'config' | 'email' | 'dns' | 'api';
public storedRouteId?: string;
public createdAt?: number; public createdAt?: number;
public updatedAt?: number; public updatedAt?: number;
@@ -22,21 +21,17 @@ export class Route {
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) { constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
this.clientRef = clientRef; this.clientRef = clientRef;
this.routeConfig = data.route; this.routeConfig = data.route;
this.source = data.source; this.id = data.id;
this.enabled = data.enabled; this.enabled = data.enabled;
this.overridden = data.overridden; this.origin = data.origin;
this.storedRouteId = data.storedRouteId;
this.createdAt = data.createdAt; this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt; this.updatedAt = data.updatedAt;
} }
public async update(changes: Partial<IRouteConfig>): Promise<void> { public async update(changes: Partial<IRouteConfig>): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
'updateRoute', 'updateRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any, this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to update route'); throw new Error(response.message || 'Failed to update route');
@@ -44,12 +39,9 @@ export class Route {
} }
public async delete(): Promise<void> { public async delete(): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
'deleteRoute', 'deleteRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any, this.clientRef.buildRequestPayload({ id: this.id }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to delete route'); throw new Error(response.message || 'Failed to delete route');
@@ -57,41 +49,15 @@ export class Route {
} }
public async toggle(enabled: boolean): Promise<void> { public async toggle(enabled: boolean): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute', 'toggleRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any, this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to toggle route'); throw new Error(response.message || 'Failed to toggle route');
} }
this.enabled = enabled; this.enabled = enabled;
} }
public async setOverride(enabled: boolean): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to set route override');
}
this.overridden = true;
this.enabled = enabled;
}
public async removeOverride(): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to remove route override');
}
this.overridden = false;
}
} }
export class RouteBuilder { export class RouteBuilder {
@@ -144,9 +110,8 @@ export class RouteBuilder {
} }
// Return a Route instance by re-fetching the list // Return a Route instance by re-fetching the list
// The created route is programmatic, so we find it by storedRouteId
const { routes } = await new RouteManager(this.clientRef).list(); const { routes } = await new RouteManager(this.clientRef).list();
const created = routes.find((r) => r.storedRouteId === response.storedRouteId); const created = routes.find((r) => r.id === response.routeId);
if (created) { if (created) {
return created; return created;
} }
@@ -154,10 +119,9 @@ export class RouteBuilder {
// Fallback: construct from known data // Fallback: construct from known data
return new Route(this.clientRef, { return new Route(this.clientRef, {
route: this.routeConfig as IRouteConfig, route: this.routeConfig as IRouteConfig,
source: 'programmatic', id: response.routeId || '',
enabled: this.isEnabled, enabled: this.isEnabled,
overridden: false, origin: 'api',
storedRouteId: response.storedRouteId,
}); });
} }
} }
@@ -190,10 +154,9 @@ export class RouteManager {
} }
return new Route(this.clientRef, { return new Route(this.clientRef, {
route: routeConfig, route: routeConfig,
source: 'programmatic', id: response.routeId || '',
enabled: enabled ?? true, enabled: enabled ?? true,
overridden: false, origin: 'api',
storedRouteId: response.storedRouteId,
}); });
} }

View File

@@ -83,24 +83,23 @@ export interface IRouteMetadata {
} }
/** /**
* A merged route combining hardcoded and programmatic sources. * A route entry returned by the route management API.
*/ */
export interface IMergedRoute { export interface IMergedRoute {
route: IDcRouterRouteConfig; route: IDcRouterRouteConfig;
source: 'hardcoded' | 'programmatic'; id: string;
enabled: boolean; enabled: boolean;
overridden: boolean; origin: 'config' | 'email' | 'dns' | 'api';
storedRouteId?: string;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
metadata?: IRouteMetadata; metadata?: IRouteMetadata;
} }
/** /**
* A warning generated during route merge/startup. * A warning generated during route startup/apply.
*/ */
export interface IRouteWarning { export interface IRouteWarning {
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override'; type: 'disabled-route';
routeName: string; routeName: string;
message: string; message: string;
} }
@@ -123,28 +122,19 @@ export interface IApiTokenInfo {
// ============================================================================ // ============================================================================
/** /**
* A programmatic route stored in /config-api/routes/{id}.json * A route persisted in the database.
*/ */
export interface IStoredRoute { export interface IRoute {
id: string; id: string;
route: IDcRouterRouteConfig; route: IDcRouterRouteConfig;
enabled: boolean; enabled: boolean;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
createdBy: string; createdBy: string;
origin: 'config' | 'email' | 'dns' | 'api';
metadata?: IRouteMetadata; metadata?: IRouteMetadata;
} }
/**
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
*/
export interface IRouteOverride {
routeName: string;
enabled: boolean;
updatedAt: number;
updatedBy: string;
}
/** /**
* A stored API token, stored in /config-api/tokens/{id}.json * A stored API token, stored in /config-api/tokens/{id}.json
*/ */

View File

@@ -143,6 +143,15 @@ export interface IHealthStatus {
version?: string; version?: string;
} }
export interface IDomainActivity {
domain: string;
bytesInPerSecond: number;
bytesOutPerSecond: number;
activeConnections: number;
routeCount: number;
requestCount: number;
}
export interface INetworkMetrics { export interface INetworkMetrics {
totalBandwidth: { totalBandwidth: {
in: number; in: number;
@@ -156,12 +165,21 @@ export interface INetworkMetrics {
connectionDetails: IConnectionDetails[]; connectionDetails: IConnectionDetails[];
topEndpoints: Array<{ topEndpoints: Array<{
endpoint: string; endpoint: string;
requests: number; connections: number;
bandwidth: { bandwidth: {
in: number; in: number;
out: number; out: number;
}; };
}>; }>;
topEndpointsByBandwidth: Array<{
endpoint: string;
connections: number;
bandwidth: {
in: number;
out: number;
};
}>;
domainActivity: IDomainActivity[];
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>; throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number; requestsPerSecond?: number;
requestsTotal?: number; requestsTotal?: number;

View File

@@ -21,7 +21,7 @@ export interface ITargetProfile {
domains?: string[]; domains?: string[];
/** Specific IP:port targets this profile grants access to */ /** Specific IP:port targets this profile grants access to */
targets?: ITargetProfileTarget[]; 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[]; routeRefs?: string[];
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;

View File

@@ -9,7 +9,7 @@ import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
// ============================================================================ // ============================================================================
/** /**
* Get all merged routes (hardcoded + programmatic) with warnings. * Get all routes with warnings.
*/ */
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -27,7 +27,7 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
} }
/** /**
* Create a new programmatic route. * Create a new route.
*/ */
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
}; };
response: { response: {
success: boolean; success: boolean;
storedRouteId?: string; routeId?: string;
message?: string; message?: string;
}; };
} }
/** /**
* Update a programmatic route. * Update a route.
*/ */
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -71,7 +71,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
} }
/** /**
* Delete a programmatic route. * Delete a route.
*/ */
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -90,46 +90,7 @@ export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.impleme
} }
/** /**
* Set an override on a hardcoded route (disable/enable by name). * Toggle a route on/off by id.
*/
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SetRouteOverride
> {
method: 'setRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Remove an override from a hardcoded route (restore default behavior).
*/
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RemoveRouteOverride
> {
method: 'removeRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Toggle a programmatic route on/off by id.
*/ */
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,

View File

@@ -180,5 +180,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
requestsPerSecond: number; requestsPerSecond: number;
requestsTotal: number; requestsTotal: number;
backends?: statsInterfaces.IBackendInfo[]; backends?: statsInterfaces.IBackendInfo[];
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
domainActivity: statsInterfaces.IDomainActivity[];
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
}; };
} }

View File

@@ -21,6 +21,30 @@ export interface IMigrationRunner {
run(): Promise<IMigrationRunResult>; 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. * 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') .step('rename-target-profile-host-to-ip')
.from('13.0.11').to('13.1.0') .from('13.0.11').to('13.1.0')
.description('Rename ITargetProfileTarget.host → ip on all target profiles') .description('Rename ITargetProfileTarget.host → ip on all target profiles')
.up(async (ctx) => { .up(async (ctx) => migrateTargetProfileTargetHosts(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)`);
})
.step('rename-domain-source-manual-to-dcrouter') .step('rename-domain-source-manual-to-dcrouter')
.from('13.1.0').to('13.8.1') .from('13.1.0').to('13.8.1')
.description('Rename DomainDoc.source value from "manual" to "dcrouter"') .description('Rename DomainDoc.source value from "manual" to "dcrouter"')
@@ -92,6 +100,40 @@ export async function createMigrationRunner(
'info', 'info',
`rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`, `rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`,
); );
})
.step('unify-routes-rename-collection')
.from('13.8.2').to('13.16.0')
.description('Rename StoredRouteDoc → RouteDoc, add origin field, drop RouteOverrideDoc')
.up(async (ctx) => {
const db = ctx.mongo!;
// 1. Rename StoredRouteDoc → RouteDoc (smartdata uses exact class names)
const collections = await db.listCollections({ name: 'StoredRouteDoc' }).toArray();
if (collections.length > 0) {
await db.renameCollection('StoredRouteDoc', 'RouteDoc');
ctx.log.log('info', 'Renamed StoredRouteDoc → RouteDoc');
}
// 2. Set origin='api' on all migrated docs (they were API-created)
const routeCol = db.collection('RouteDoc');
const result = await routeCol.updateMany(
{ origin: { $exists: false } },
{ $set: { origin: 'api' } },
);
ctx.log.log('info', `Set origin='api' on ${result.modifiedCount} migrated route(s)`);
// 3. Drop RouteOverrideDoc collection
const overrideCollections = await db.listCollections({ name: 'RouteOverrideDoc' }).toArray();
if (overrideCollections.length > 0) {
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; return migration;

View File

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

View File

@@ -52,7 +52,9 @@ export interface INetworkState {
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number }; throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
totalBytes: { in: number; out: number }; totalBytes: { in: number; out: number };
topIPs: Array<{ ip: string; count: number }>; topIPs: Array<{ ip: string; count: number }>;
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
throughputByIP: Array<{ ip: string; in: number; out: number }>; throughputByIP: Array<{ ip: string; in: number; out: number }>;
domainActivity: interfaces.data.IDomainActivity[];
throughputHistory: Array<{ timestamp: number; in: number; out: number }>; throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond: number; requestsPerSecond: number;
requestsTotal: number; requestsTotal: number;
@@ -160,7 +162,9 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
totalBytes: { in: 0, out: 0 }, totalBytes: { in: 0, out: 0 },
topIPs: [], topIPs: [],
topIPsByBandwidth: [],
throughputByIP: [], throughputByIP: [],
domainActivity: [],
throughputHistory: [], throughputHistory: [],
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
@@ -518,14 +522,13 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
}); });
// Get network stats for throughput and IP data // Get network stats for throughput and IP data
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest( const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
'/typedrequest', interfaces.requests.IReq_GetNetworkStats
'getNetworkStats' >('/typedrequest', 'getNetworkStats');
);
const networkStatsResponse = await networkStatsRequest.fire({ const networkStatsResponse = await networkStatsRequest.fire({
identity: context.identity, identity: context.identity,
}) as any; });
// Use the connections data for the connection list // Use the connections data for the connection list
// and network stats for throughput and IP analytics // and network stats for throughput and IP analytics
@@ -552,7 +555,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut } ? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut }
: { in: 0, out: 0 }, : { in: 0, out: 0 },
topIPs: networkStatsResponse.topIPs || [], topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
throughputByIP: networkStatsResponse.throughputByIP || [], throughputByIP: networkStatsResponse.throughputByIP || [],
domainActivity: networkStatsResponse.domainActivity || [],
throughputHistory: networkStatsResponse.throughputHistory || [], throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0, requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
requestsTotal: networkStatsResponse.requestsTotal || 0, requestsTotal: networkStatsResponse.requestsTotal || 0,
@@ -2214,58 +2219,6 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
} }
}); });
export const setRouteOverrideAction = routeManagementStatePart.createAction<{
routeName: string;
enabled: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_SetRouteOverride
>('/typedrequest', 'setRouteOverride');
await request.fire({
identity: context.identity!,
routeName: dataArg.routeName,
enabled: dataArg.enabled,
});
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to set override',
};
}
});
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
async (statePartArg, routeName, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RemoveRouteOverride
>('/typedrequest', 'removeRouteOverride');
await request.fire({
identity: context.identity!,
routeName,
});
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to remove override',
};
}
}
);
// ============================================================================ // ============================================================================
// API Token Actions // API Token Actions
// ============================================================================ // ============================================================================
@@ -2650,66 +2603,51 @@ async function dispatchCombinedRefreshActionInner() {
const network = combinedResponse.metrics.network; const network = combinedResponse.metrics.network;
const connectionsByIP: { [ip: string]: number } = {}; const connectionsByIP: { [ip: string]: number } = {};
// Convert connection details to IP counts // Build connectionsByIP from connectionDetails (now populated with real per-IP data)
network.connectionDetails.forEach(conn => { network.connectionDetails.forEach(conn => {
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1; connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1;
}); });
// Fetch detailed connections for the network view // Build connections from connectionDetails (real per-IP aggregates)
try { const connections: interfaces.data.IConnectionInfo[] = network.connectionDetails.map((conn, i) => ({
const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< id: `ip-${conn.remoteAddress}`,
interfaces.requests.IReq_GetActiveConnections remoteAddress: conn.remoteAddress,
>('/typedrequest', 'getActiveConnections'); localAddress: 'server',
startTime: conn.startTime,
protocol: conn.protocol as any,
state: conn.state as any,
bytesReceived: conn.bytesIn,
bytesSent: conn.bytesOut,
}));
const connectionsResponse = await connectionsRequest.fire({ networkStatePart.setState({
identity: context.identity, ...networkStatePart.getState()!,
}); connections,
connectionsByIP,
networkStatePart.setState({ throughputRate: {
...networkStatePart.getState()!, bytesInPerSecond: network.totalBandwidth.in,
connections: connectionsResponse.connections, bytesOutPerSecond: network.totalBandwidth.out,
connectionsByIP, },
throughputRate: { totalBytes: network.totalBytes || { in: 0, out: 0 },
bytesInPerSecond: network.totalBandwidth.in, topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.connections })),
bytesOutPerSecond: network.totalBandwidth.out topIPsByBandwidth: (network.topEndpointsByBandwidth || []).map(e => ({
}, ip: e.endpoint,
totalBytes: network.totalBytes || { in: 0, out: 0 }, count: e.connections,
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })), bwIn: e.bandwidth?.in || 0,
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })), bwOut: e.bandwidth?.out || 0,
throughputHistory: network.throughputHistory || [], })),
requestsPerSecond: network.requestsPerSecond || 0, throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
requestsTotal: network.requestsTotal || 0, domainActivity: network.domainActivity || [],
backends: network.backends || [], throughputHistory: network.throughputHistory || [],
frontendProtocols: network.frontendProtocols || null, requestsPerSecond: network.requestsPerSecond || 0,
backendProtocols: network.backendProtocols || null, requestsTotal: network.requestsTotal || 0,
lastUpdated: Date.now(), backends: network.backends || [],
isLoading: false, frontendProtocols: network.frontendProtocols || null,
error: null, backendProtocols: network.backendProtocols || null,
}); lastUpdated: Date.now(),
} catch (error: unknown) { isLoading: false,
console.error('Failed to fetch connections:', error); error: null,
networkStatePart.setState({ });
...networkStatePart.getState()!,
connections: [],
connectionsByIP,
throughputRate: {
bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out
},
totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
frontendProtocols: network.frontendProtocols || null,
backendProtocols: network.backendProtocols || null,
lastUpdated: Date.now(),
isLoading: false,
error: null,
});
}
} }
// Refresh certificate data if on Domains > Certificates subview // Refresh certificate data if on Domains > Certificates subview

View File

@@ -323,16 +323,14 @@ export class OpsViewDomains extends DeesElement {
// Build target options based on current source // Build target options based on current source
const targetOptions: { option: string; key: string }[] = []; const targetOptions: { option: string; key: string }[] = [];
if (domain.source === 'provider') {
targetOptions.push({ option: 'DcRouter (authoritative)', key: 'dcrouter' });
}
// Add all providers (except the current one if already provider-managed)
for (const p of providers) { for (const p of providers) {
if (domain.source === 'provider' && domain.providerId === p.id) continue; // Skip current source
targetOptions.push({ option: `${p.name} (${p.type})`, key: `provider:${p.id}` }); if (p.builtIn && domain.source === 'dcrouter') continue;
} if (!p.builtIn && domain.source === 'provider' && domain.providerId === p.id) continue;
if (domain.source === 'dcrouter') {
targetOptions.unshift({ option: 'DcRouter (authoritative)', key: 'dcrouter' }); const label = p.builtIn ? 'DcRouter (self)' : `${p.name} (${p.type})`;
const key = p.builtIn ? 'dcrouter' : `provider:${p.id}`;
targetOptions.push({ option: label, key });
} }
if (targetOptions.length === 0) { if (targetOptions.length === 0) {
@@ -345,7 +343,7 @@ export class OpsViewDomains extends DeesElement {
} }
const currentLabel = domain.source === 'dcrouter' const currentLabel = domain.source === 'dcrouter'
? 'DcRouter (authoritative)' ? 'DcRouter (self)'
: providers.find((p) => p.id === domain.providerId)?.name || 'Provider'; : providers.find((p) => p.id === domain.providerId)?.name || 'Provider';
DeesModal.createAndShow({ DeesModal.createAndShow({

View File

@@ -10,22 +10,6 @@ declare global {
} }
} }
interface INetworkRequest {
id: string;
timestamp: number;
method: string;
url: string;
hostname: string;
port: number;
protocol: 'http' | 'https' | 'tcp' | 'udp';
statusCode?: number;
duration: number;
bytesIn: number;
bytesOut: number;
remoteIp: string;
route?: string;
}
@customElement('ops-view-network-activity') @customElement('ops-view-network-activity')
export class OpsViewNetworkActivity extends DeesElement { export class OpsViewNetworkActivity extends DeesElement {
/** How far back the traffic chart shows */ /** How far back the traffic chart shows */
@@ -42,9 +26,6 @@ export class OpsViewNetworkActivity extends DeesElement {
accessor networkState = appstate.networkStatePart.getState()!; accessor networkState = appstate.networkStatePart.getState()!;
@state()
accessor networkRequests: INetworkRequest[] = [];
@state() @state()
accessor trafficDataIn: Array<{ x: string | number; y: number }> = []; accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
@@ -314,108 +295,21 @@ export class OpsViewNetworkActivity extends DeesElement {
<!-- Protocol Distribution Charts --> <!-- Protocol Distribution Charts -->
${this.renderProtocolCharts()} ${this.renderProtocolCharts()}
<!-- Top IPs Section --> <!-- Top IPs by Connection Count -->
${this.renderTopIPs()} ${this.renderTopIPs()}
<!-- Top IPs by Bandwidth -->
${this.renderTopIPsByBandwidth()}
<!-- Domain Activity -->
${this.renderDomainActivity()}
<!-- Backend Protocols Section --> <!-- Backend Protocols Section -->
${this.renderBackendProtocols()} ${this.renderBackendProtocols()}
<!-- Requests Table -->
<dees-table
.data=${this.networkRequests}
.rowKey=${'id'}
.highlightUpdates=${'flash'}
.displayFunction=${(req: INetworkRequest) => ({
Time: new Date(req.timestamp).toLocaleTimeString(),
Protocol: html`<span class="protocolBadge ${req.protocol}">${req.protocol.toUpperCase()}</span>`,
Method: req.method,
'Host:Port': `${req.hostname}:${req.port}`,
Path: this.truncateUrl(req.url),
Status: this.renderStatus(req.statusCode),
Duration: `${req.duration}ms`,
'In/Out': `${this.formatBytes(req.bytesIn)} / ${this.formatBytes(req.bytesOut)}`,
'Remote IP': req.remoteIp,
})}
.dataActions=${[
{
name: 'View Details',
iconName: 'fa:magnifyingGlass',
type: ['inRow', 'doubleClick', 'contextmenu'],
actionFunc: async (actionData) => {
await this.showRequestDetails(actionData.item);
}
}
]}
heading1="Recent Network Activity"
heading2="Recent network requests"
searchable
.showColumnFilters=${true}
.pagination=${true}
.paginationSize=${50}
dataName="request"
></dees-table>
</div> </div>
`; `;
} }
private async showRequestDetails(request: INetworkRequest) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Request Details',
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Request Information'}
progLang="json"
.codeToDisplay=${JSON.stringify({
id: request.id,
timestamp: new Date(request.timestamp).toISOString(),
protocol: request.protocol,
method: request.method,
url: request.url,
hostname: request.hostname,
port: request.port,
statusCode: request.statusCode,
duration: `${request.duration}ms`,
bytesIn: request.bytesIn,
bytesOut: request.bytesOut,
remoteIp: request.remoteIp,
route: request.route,
}, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Request ID',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(request.id);
}
}
]
});
}
private renderStatus(statusCode?: number): TemplateResult {
if (!statusCode) {
return html`<span class="statusBadge warning">N/A</span>`;
}
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
statusCode >= 400 ? 'error' : 'warning';
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
}
private truncateUrl(url: string, maxLength = 50): string {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength - 3) + '...';
}
private formatNumber(num: number): string { private formatNumber(num: number): string {
if (num >= 1000000) { if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'; return (num / 1000000).toFixed(1) + 'M';
@@ -480,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',
@@ -619,6 +513,67 @@ export class OpsViewNetworkActivity extends DeesElement {
`; `;
} }
private renderTopIPsByBandwidth(): TemplateResult {
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.topIPsByBandwidth}
.rowKey=${'ip'}
.highlightUpdates=${'flash'}
.displayFunction=${(ipData: { ip: string; count: number; bwIn: number; bwOut: number }) => {
return {
'IP Address': ipData.ip,
'Bandwidth In': this.formatBitsPerSecond(ipData.bwIn),
'Bandwidth Out': this.formatBitsPerSecond(ipData.bwOut),
'Total Bandwidth': this.formatBitsPerSecond(ipData.bwIn + ipData.bwOut),
'Connections': ipData.count,
};
}}
heading1="Top IPs by Bandwidth"
heading2="IPs with highest throughput"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ip"
></dees-table>
`;
}
private renderDomainActivity(): TemplateResult {
if (!this.networkState.domainActivity || this.networkState.domainActivity.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.domainActivity}
.rowKey=${'domain'}
.highlightUpdates=${'flash'}
.displayFunction=${(item: interfaces.data.IDomainActivity) => {
const totalBytesPerMin = (item.bytesInPerSecond + item.bytesOutPerSecond) * 60;
return {
'Domain': item.domain,
'Throughput In': this.formatBitsPerSecond(item.bytesInPerSecond),
'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 from request-level metrics"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="domain"
></dees-table>
`;
}
private renderBackendProtocols(): TemplateResult { private renderBackendProtocols(): TemplateResult {
const backends = this.networkState.backends; const backends = this.networkState.backends;
if (!backends || backends.length === 0) { if (!backends || backends.length === 0) {
@@ -730,25 +685,6 @@ export class OpsViewNetworkActivity extends DeesElement {
this.requestsPerSecHistory.shift(); this.requestsPerSecHistory.shift();
} }
// Reassign unconditionally so dees-table's flash diff can compare per-cell
// values against the previous snapshot. Row identity is preserved via
// rowKey='id', so DOM nodes are reused across ticks.
this.networkRequests = this.networkState.connections.map((conn) => ({
id: conn.id,
timestamp: conn.startTime,
method: 'GET', // Default method for proxy connections
url: '/',
hostname: conn.remoteAddress,
port: conn.protocol === 'https' ? 443 : 80,
protocol: conn.protocol === 'https' || conn.protocol === 'http' ? conn.protocol : 'tcp',
statusCode: conn.state === 'connected' ? 200 : undefined,
duration: Date.now() - conn.startTime,
bytesIn: conn.bytesReceived,
bytesOut: conn.bytesSent,
remoteIp: conn.remoteAddress,
route: 'proxy',
}));
// Load server-side throughput history into chart (once) // Load server-side throughput history into chart (once)
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) { if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
this.loadThroughputHistory(); this.loadThroughputHistory();

View File

@@ -49,6 +49,8 @@ function setupTlsVisibility(formEl: any) {
@customElement('ops-view-routes') @customElement('ops-view-routes')
export class OpsViewRoutes extends DeesElement { export class OpsViewRoutes extends DeesElement {
@state() accessor routeFilter: 'User Routes' | 'System Routes' = 'User Routes';
@state() accessor routeState: appstate.IRouteManagementState = { @state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [], mergedRoutes: [],
warnings: [], warnings: [],
@@ -140,9 +142,9 @@ export class OpsViewRoutes extends DeesElement {
public render(): TemplateResult { public render(): TemplateResult {
const { mergedRoutes, warnings } = this.routeState; const { mergedRoutes, warnings } = this.routeState;
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length; const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
const configCount = mergedRoutes.filter((mr) => mr.origin !== 'api').length;
const apiCount = mergedRoutes.filter((mr) => mr.origin === 'api').length;
const statsTiles: IStatsTile[] = [ const statsTiles: IStatsTile[] = [
{ {
@@ -155,21 +157,21 @@ export class OpsViewRoutes extends DeesElement {
color: '#3b82f6', color: '#3b82f6',
}, },
{ {
id: 'hardcoded', id: 'configRoutes',
title: 'Hardcoded', title: 'System Routes',
type: 'number', type: 'number',
value: hardcodedCount, value: configCount,
icon: 'lucide:lock', icon: 'lucide:settings',
description: 'Routes from constructor config', description: 'From config, email, and DNS',
color: '#8b5cf6', color: '#8b5cf6',
}, },
{ {
id: 'programmatic', id: 'apiRoutes',
title: 'Programmatic', title: 'User Routes',
type: 'number', type: 'number',
value: programmaticCount, value: apiCount,
icon: 'lucide:code', icon: 'lucide:code',
description: 'Routes added via API', description: 'Created via API',
color: '#0ea5e9', color: '#0ea5e9',
}, },
{ {
@@ -183,18 +185,23 @@ export class OpsViewRoutes extends DeesElement {
}, },
]; ];
// Map merged routes to sz-route-list-view format // Filter routes based on selected tab
const szRoutes = mergedRoutes.map((mr) => { 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 || [])]; const tags = [...(mr.route.tags || [])];
tags.push(mr.source); tags.push(mr.origin);
if (!mr.enabled) tags.push('disabled'); if (!mr.enabled) tags.push('disabled');
if (mr.overridden) tags.push('overridden');
return { return {
...mr.route, ...mr.route,
enabled: mr.enabled, enabled: mr.enabled,
tags, tags,
id: mr.storedRouteId || mr.route.name || undefined, id: mr.id || mr.route.name || undefined,
metadata: mr.metadata, metadata: mr.metadata,
}; };
}); });
@@ -219,6 +226,13 @@ export class OpsViewRoutes extends DeesElement {
]} ]}
></dees-statsgrid> ></dees-statsgrid>
<dees-input-multitoggle
class="routeFilterToggle"
.type=${'single'}
.options=${['User Routes', 'System Routes']}
.selectedOption=${this.routeFilter}
></dees-input-multitoggle>
${warnings.length > 0 ${warnings.length > 0
? html` ? html`
<div class="warnings-bar"> <div class="warnings-bar">
@@ -238,7 +252,7 @@ export class OpsViewRoutes extends DeesElement {
? html` ? html`
<sz-route-list-view <sz-route-list-view
.routes=${szRoutes} .routes=${szRoutes}
.showActionsFilter=${(route: any) => route.tags?.includes('programmatic') ?? false} .showActionsFilter=${isUserRoutes ? () => true : () => false}
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)} @route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)} @route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)} @route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
@@ -246,8 +260,8 @@ export class OpsViewRoutes extends DeesElement {
` `
: html` : html`
<div class="empty-state"> <div class="empty-state">
<p>No routes configured</p> <p>No ${isUserRoutes ? 'user' : 'system'} routes</p>
<p>Add a programmatic route or check your constructor configuration.</p> <p>${isUserRoutes ? 'Add a route to get started.' : 'System routes are generated from config, email, and DNS settings.'}</p>
</div> </div>
`} `}
</div> </div>
@@ -266,112 +280,56 @@ export class OpsViewRoutes extends DeesElement {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
if (merged.source === 'hardcoded') { const meta = merged.metadata;
const menuOptions = merged.enabled await DeesModal.createAndShow({
? [ heading: `Route: ${merged.route.name}`,
{ content: html`
name: 'Disable Route', <div style="color: #ccc; padding: 8px 0;">
iconName: 'lucide:pause', <p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
action: async (modalArg: any) => { <p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
await appstate.routeManagementStatePart.dispatchAction( <p>ID: <code style="color: #888;">${merged.id}</code></p>
appstate.setRouteOverrideAction, ${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
{ routeName: merged.route.name!, enabled: false }, ${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
); </div>
await modalArg.destroy(); `,
}, menuOptions: [
}, {
{ name: merged.enabled ? 'Disable' : 'Enable',
name: 'Close', iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
iconName: 'lucide:x', action: async (modalArg: any) => {
action: async (modalArg: any) => await modalArg.destroy(), await appstate.routeManagementStatePart.dispatchAction(
}, appstate.toggleRouteAction,
] { id: merged.id, enabled: !merged.enabled },
: [ );
{ await modalArg.destroy();
name: 'Enable Route',
iconName: 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.setRouteOverrideAction,
{ routeName: merged.route.name!, enabled: true },
);
await modalArg.destroy();
},
},
{
name: 'Remove Override',
iconName: 'lucide:undo',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.removeRouteOverrideAction,
merged.route.name!,
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
];
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
</div>
`,
menuOptions,
});
} else {
// Programmatic route
const meta = merged.metadata;
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div>
`,
menuOptions: [
{
name: merged.enabled ? 'Disable' : 'Enable',
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleRouteAction,
{ id: merged.storedRouteId!, enabled: !merged.enabled },
);
await modalArg.destroy();
},
}, },
{ },
name: 'Delete', {
iconName: 'lucide:trash-2', name: 'Edit',
action: async (modalArg: any) => { iconName: 'lucide:pencil',
await appstate.routeManagementStatePart.dispatchAction( action: async (modalArg: any) => {
appstate.deleteRouteAction, await modalArg.destroy();
merged.storedRouteId!, this.showEditRouteDialog(merged);
);
await modalArg.destroy();
},
}, },
{ },
name: 'Close', {
iconName: 'lucide:x', name: 'Delete',
action: async (modalArg: any) => await modalArg.destroy(), iconName: 'lucide:trash-2',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction,
merged.id,
);
await modalArg.destroy();
}, },
], },
}); {
} name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
],
});
} }
private async handleRouteEdit(e: CustomEvent) { private async handleRouteEdit(e: CustomEvent) {
@@ -381,7 +339,7 @@ export class OpsViewRoutes extends DeesElement {
const merged = this.routeState.mergedRoutes.find( const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name, (mr) => mr.route.name === clickedRoute.name,
); );
if (!merged || !merged.storedRouteId) return; if (!merged) return;
this.showEditRouteDialog(merged); this.showEditRouteDialog(merged);
} }
@@ -393,7 +351,7 @@ export class OpsViewRoutes extends DeesElement {
const merged = this.routeState.mergedRoutes.find( const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name, (mr) => mr.route.name === clickedRoute.name,
); );
if (!merged || !merged.storedRouteId) return; if (!merged) return;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({ await DeesModal.createAndShow({
@@ -415,7 +373,7 @@ export class OpsViewRoutes extends DeesElement {
action: async (modalArg: any) => { action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction, appstate.deleteRouteAction,
merged.storedRouteId!, merged.id,
); );
await modalArg.destroy(); await modalArg.destroy();
}, },
@@ -563,7 +521,7 @@ export class OpsViewRoutes extends DeesElement {
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
appstate.updateRouteAction, appstate.updateRouteAction,
{ {
id: merged.storedRouteId!, id: merged.id,
route: updatedRoute, route: updatedRoute,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined, metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
}, },
@@ -603,7 +561,7 @@ export class OpsViewRoutes extends DeesElement {
]; ];
const createModal = await DeesModal.createAndShow({ const createModal = await DeesModal.createAndShow({
heading: 'Add Programmatic Route', heading: 'Add Route',
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text> <dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
@@ -719,5 +677,13 @@ export class OpsViewRoutes extends DeesElement {
async firstUpdated() { async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); 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>`)}` ? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
: '-', : '-',
'Route Refs': profile.routeRefs?.length '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(), 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 routeState = appstate.routeManagementStatePart.getState();
const routes = routeState?.mergedRoutes || []; const routes = routeState?.mergedRoutes || [];
return routes return routes
.filter((mr) => mr.route.name) .filter((mr) => mr.route.name && mr.id)
.map((mr) => ({ viewKey: mr.route.name! })); .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() { 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)); .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, { await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name), name: String(data.name),
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) { private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains || []; const currentDomains = profile.domains || [];
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || []; 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'); const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded(); 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)); .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, { await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id, 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: 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;"> <div style="font-size: 14px; margin-top: 4px;">
${profile.routeRefs?.length ${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>
</div> </div>

View File

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