Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20ea0ce683 | |||
| bcea93753b | |||
| 848515e424 | |||
| 38c9978969 | |||
| ee863b8178 | |||
| 9bb5a8bcc1 | |||
| 5aa07e81c7 | |||
| aec8b72ca3 | |||
| 466654ee4c | |||
| f1a11e3f6a | |||
| e193b3a8eb | |||
| 1bbf31605c | |||
| f2cfa923a0 | |||
| cdc77305e5 | |||
| 835537f789 | |||
| 754b223f62 | |||
| 0a39d50d20 | |||
| de7b9f7ec5 | |||
| bd959464c7 | |||
| 36b629676f | |||
| 19398ea836 | |||
| 4aba8cc353 |
17
AGENTS.md
Normal file
17
AGENTS.md
Normal 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.
|
||||
72
changelog.md
72
changelog.md
@@ -1,5 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-14 - 13.17.8 - fix(opsserver)
|
||||
align certificate status handling with the updated smartproxy response format
|
||||
|
||||
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
|
||||
- bump @push.rocks/smartproxy to ^27.7.3
|
||||
- enable verbose output for the test script
|
||||
|
||||
## 2026-04-14 - 13.17.7 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-14 - 13.17.6 - fix(dns,routes)
|
||||
keep DoH socket-handler routes runtime-only and prune stale persisted entries
|
||||
|
||||
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
|
||||
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
|
||||
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
|
||||
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
|
||||
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
|
||||
|
||||
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
|
||||
normalize target profile route references and stabilize VPN host-IP client routing behavior
|
||||
|
||||
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
|
||||
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
|
||||
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
|
||||
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
|
||||
|
||||
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
|
||||
sync route filter toggle selection via component changeSubject
|
||||
|
||||
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
|
||||
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
|
||||
|
||||
## 2026-04-13 - 13.17.2 - fix(monitoring)
|
||||
exclude unconfigured routes from domain activity aggregation
|
||||
|
||||
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
|
||||
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
|
||||
|
||||
## 2026-04-13 - 13.17.1 - fix(monitoring)
|
||||
stop allocating route metrics to domains when no request data exists
|
||||
|
||||
- Removes the equal-split fallback for shared routes in MetricsManager.
|
||||
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
|
||||
|
||||
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
|
||||
add request-based domain activity metrics and split routes into user and system views
|
||||
|
||||
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
|
||||
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
|
||||
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
|
||||
|
||||
## 2026-04-13 - 13.16.2 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^27.6.0
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.15.1",
|
||||
"version": "13.17.8",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||
"start": "(node ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
@@ -51,10 +51,10 @@
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmigration": "1.2.0",
|
||||
"@push.rocks/smartmta": "^5.3.1",
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartnetwork": "^4.6.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.5.0",
|
||||
"@push.rocks/smartproxy": "^27.7.3",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
@@ -62,12 +62,12 @@
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.2",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.3",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.3.3",
|
||||
"lru-cache": "^11.3.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@@ -72,8 +72,8 @@ importers:
|
||||
specifier: ^5.3.1
|
||||
version: 5.3.1
|
||||
'@push.rocks/smartnetwork':
|
||||
specifier: ^4.5.2
|
||||
version: 4.5.2
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0
|
||||
'@push.rocks/smartpath':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -81,8 +81,8 @@ importers:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.5.0
|
||||
version: 27.5.0
|
||||
specifier: ^27.7.3
|
||||
version: 27.7.3
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -105,8 +105,8 @@ importers:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
'@serve.zone/catalog':
|
||||
specifier: ^2.12.3
|
||||
version: 2.12.3(@tiptap/pm@2.27.2)
|
||||
specifier: ^2.12.4
|
||||
version: 2.12.4(@tiptap/pm@2.27.2)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -120,8 +120,8 @@ importers:
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
lru-cache:
|
||||
specifier: ^11.3.3
|
||||
version: 11.3.3
|
||||
specifier: ^11.3.5
|
||||
version: 11.3.5
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -365,9 +365,6 @@ packages:
|
||||
'@design.estate/dees-element@2.2.4':
|
||||
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
||||
|
||||
'@design.estate/dees-wcctools@3.8.0':
|
||||
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
||||
|
||||
'@design.estate/dees-wcctools@3.9.0':
|
||||
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
||||
|
||||
@@ -1260,8 +1257,8 @@ packages:
|
||||
'@push.rocks/smartmustache@3.0.2':
|
||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||
|
||||
'@push.rocks/smartnetwork@4.5.2':
|
||||
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
|
||||
'@push.rocks/smartnetwork@4.6.0':
|
||||
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
|
||||
|
||||
'@push.rocks/smartnftables@1.1.0':
|
||||
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||
@@ -1287,8 +1284,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.3':
|
||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||
|
||||
'@push.rocks/smartproxy@27.5.0':
|
||||
resolution: {integrity: sha512-QIXrVQtAoqBCv+9ScLOdGcizN55svJuGCfMDsDaBVtwS3Tva30IxuEL3usNTHABveuI8slaWzSxTabmTULDOwA==}
|
||||
'@push.rocks/smartproxy@27.7.3':
|
||||
resolution: {integrity: sha512-1NER+2nM2GWzrMB1xoCzkV9in8a1gWNwOimgE/f2kPYnhy5DsA2XX/KiLGJ0ge3cpCVgoK1nNe3byXQHWvmPbA==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5':
|
||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||
@@ -1591,8 +1588,8 @@ packages:
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
'@serve.zone/catalog@2.12.3':
|
||||
resolution: {integrity: sha512-/QLFjFcy/ig6cdr4517smSc/VCutW/qF/8lCM3v7tpQ5yLApjqiL314Dyvk9zzSwHpw69IeuM9EmPOeTuCY0iQ==}
|
||||
'@serve.zone/catalog@2.12.4':
|
||||
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||
@@ -3085,8 +3082,8 @@ packages:
|
||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
lru-cache@11.3.3:
|
||||
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
|
||||
lru-cache@11.3.5:
|
||||
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@7.18.3:
|
||||
@@ -4937,18 +4934,6 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@design.estate/dees-wcctools@3.8.0':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
lit: 3.3.2
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@design.estate/dees-wcctools@3.9.0':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
@@ -5169,7 +5154,7 @@ snapshots:
|
||||
'@push.rocks/smartjson': 6.0.0
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
|
||||
'@push.rocks/smartnetwork': 4.5.2
|
||||
'@push.rocks/smartnetwork': 4.6.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrequest': 5.0.1
|
||||
@@ -5972,7 +5957,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartdns': 7.9.0
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartnetwork': 4.5.2
|
||||
'@push.rocks/smartnetwork': 4.6.0
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@push.rocks/smarttime': 4.2.3
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
@@ -6429,7 +6414,7 @@ snapshots:
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
lru-cache: 11.3.3
|
||||
lru-cache: 11.3.5
|
||||
mailparser: 3.9.6
|
||||
uuid: 13.0.0
|
||||
transitivePeerDependencies:
|
||||
@@ -6439,7 +6424,7 @@ snapshots:
|
||||
dependencies:
|
||||
handlebars: 4.7.9
|
||||
|
||||
'@push.rocks/smartnetwork@4.5.2':
|
||||
'@push.rocks/smartnetwork@4.6.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdns': 7.9.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
@@ -6500,7 +6485,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartfs': 1.5.0
|
||||
'@push.rocks/smartjimp': 1.2.0
|
||||
'@push.rocks/smartnetwork': 4.5.2
|
||||
'@push.rocks/smartnetwork': 4.6.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
|
||||
@@ -6521,7 +6506,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.3': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.5.0':
|
||||
'@push.rocks/smartproxy@27.7.3':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
@@ -6923,12 +6908,12 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
selderee: 0.11.0
|
||||
|
||||
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
||||
'@serve.zone/catalog@2.12.4(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
'@design.estate/dees-wcctools': 3.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- '@tiptap/pm'
|
||||
@@ -8659,7 +8644,7 @@ snapshots:
|
||||
|
||||
lowercase-keys@3.0.0: {}
|
||||
|
||||
lru-cache@11.3.3: {}
|
||||
lru-cache@11.3.5: {}
|
||||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
@@ -9304,7 +9289,7 @@ snapshots:
|
||||
|
||||
path-scurry@2.0.2:
|
||||
dependencies:
|
||||
lru-cache: 11.3.3
|
||||
lru-cache: 11.3.5
|
||||
minipass: 7.1.3
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
|
||||
@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
||||
},
|
||||
source: 'programmatic',
|
||||
id: 'route-123',
|
||||
enabled: true,
|
||||
overridden: false,
|
||||
storedRouteId: 'route-123',
|
||||
origin: 'api',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
});
|
||||
|
||||
expect(route.name).toEqual('test-route');
|
||||
expect(route.source).toEqual('programmatic');
|
||||
expect(route.id).toEqual('route-123');
|
||||
expect(route.enabled).toEqual(true);
|
||||
expect(route.overridden).toEqual(false);
|
||||
expect(route.storedRouteId).toEqual('route-123');
|
||||
expect(route.origin).toEqual('api');
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
230
test/test.dns-runtime-routes.node.ts
Normal file
230
test/test.dns-runtime-routes.node.ts
Normal 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();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.15.1',
|
||||
version: '13.17.8',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -312,8 +312,11 @@ export class DcRouter {
|
||||
// TypedRouter for API endpoints
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
|
||||
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
||||
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
|
||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
@@ -545,11 +548,12 @@ export class DcRouter {
|
||||
await this.referenceResolver.initialize();
|
||||
|
||||
// Initialize target profile manager
|
||||
this.targetProfileManager = new TargetProfileManager();
|
||||
this.targetProfileManager = new TargetProfileManager(
|
||||
() => this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
await this.targetProfileManager.initialize();
|
||||
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.options.vpnConfig?.enabled
|
||||
@@ -559,12 +563,15 @@ export class DcRouter {
|
||||
return [];
|
||||
}
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route, routeId, this.vpnManager.listClients(),
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
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
|
||||
(routes) => {
|
||||
if (this.remoteIngressManager) {
|
||||
@@ -574,10 +581,15 @@ export class DcRouter {
|
||||
this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
},
|
||||
() => this.runtimeDnsRoutes,
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
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
|
||||
const seeder = new DbSeeder(this.referenceResolver);
|
||||
@@ -881,31 +893,30 @@ export class DcRouter {
|
||||
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.
|
||||
// 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
|
||||
this.seedEmailRoutes = [];
|
||||
if (this.options.emailConfig) {
|
||||
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
||||
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
||||
this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
||||
}
|
||||
|
||||
// If DNS is configured, add DNS routes
|
||||
this.runtimeDnsRoutes = [];
|
||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||
const dnsRoutes = this.generateDnsRoutes();
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
||||
routes = [...routes, ...dnsRoutes];
|
||||
this.runtimeDnsRoutes = this.generateDnsRoutes();
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) });
|
||||
}
|
||||
|
||||
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
||||
let routes: plugins.smartproxy.IRouteConfig[] = [
|
||||
...this.seedConfigRoutes,
|
||||
...this.seedEmailRoutes,
|
||||
...this.runtimeDnsRoutes,
|
||||
];
|
||||
|
||||
// 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
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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 (routes.length > 0 || this.options.smartProxyConfig) {
|
||||
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||
@@ -1406,14 +1413,6 @@ export class DcRouter {
|
||||
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() {
|
||||
logger.log('info', 'Stopping DcRouter services...');
|
||||
|
||||
@@ -1457,17 +1456,15 @@ export class DcRouter {
|
||||
// Update configuration
|
||||
this.options.smartProxyConfig = config;
|
||||
|
||||
// Update routes on RemoteIngressManager so derived ports stay in sync
|
||||
if (this.remoteIngressManager && config.routes) {
|
||||
this.remoteIngressManager.setRoutes(config.routes as any[]);
|
||||
}
|
||||
|
||||
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
||||
// Start new SmartProxy with updated configuration (rebuilds seed routes)
|
||||
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) {
|
||||
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');
|
||||
@@ -2185,13 +2182,14 @@ export class DcRouter {
|
||||
this.remoteIngressManager = new RemoteIngressManager();
|
||||
await this.remoteIngressManager.initialize();
|
||||
|
||||
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
||||
const currentRoutes = this.constructorRoutes;
|
||||
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||
// will push the complete merged routes here.
|
||||
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
|
||||
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
||||
|
||||
// Race-condition fix: if ConfigManagers finished before us, re-apply routes
|
||||
// so the callback delivers the full merged set (including DB-stored routes)
|
||||
// to our newly-created remoteIngressManager.
|
||||
// If ConfigManagers finished before us, re-apply routes
|
||||
// so the callback delivers the full DB set to our newly-created remoteIngressManager.
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.applyRoutes();
|
||||
}
|
||||
@@ -2278,11 +2276,10 @@ export class DcRouter {
|
||||
|
||||
if (!this.targetProfileManager) return [...ips];
|
||||
|
||||
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[];
|
||||
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
|
||||
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
|
||||
|
||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||
targetProfileIds, routes, storedRoutes,
|
||||
targetProfileIds, allRoutes,
|
||||
);
|
||||
|
||||
// Add target IPs directly
|
||||
@@ -2292,8 +2289,11 @@ export class DcRouter {
|
||||
|
||||
// Resolve DNS A records for matched domains (with caching)
|
||||
for (const domain of domains) {
|
||||
const stripped = domain.replace(/^\*\./, '');
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
||||
if (this.isWildcardVpnDomain(domain)) {
|
||||
this.logSkippedWildcardAllowedIp(domain);
|
||||
continue;
|
||||
}
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||
for (const ip of resolvedIps) {
|
||||
ips.add(`${ip}/32`);
|
||||
}
|
||||
@@ -2305,14 +2305,15 @@ export class DcRouter {
|
||||
|
||||
await this.vpnManager.start();
|
||||
|
||||
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
||||
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
|
||||
// VPN server wasn't ready yet)
|
||||
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
|
||||
// get correct profile-based ipAllowLists
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
||||
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
|
||||
private warnedWildcardVpnDomains = new Set<string>();
|
||||
|
||||
/**
|
||||
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
||||
@@ -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()
|
||||
// via the getVpnAllowList callback — no longer a separate method here.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
ISourceProfile,
|
||||
INetworkTarget,
|
||||
IRouteMetadata,
|
||||
IStoredRoute,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
@@ -81,7 +81,7 @@ export class ReferenceResolver {
|
||||
public async deleteProfile(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IStoredRoute>,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
@@ -131,7 +131,7 @@ export class ReferenceResolver {
|
||||
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 }>>();
|
||||
for (const profile of this.profiles.values()) {
|
||||
usage.set(profile.id, []);
|
||||
@@ -147,7 +147,7 @@ export class ReferenceResolver {
|
||||
|
||||
public getProfileUsageForId(
|
||||
profileId: string,
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
@@ -214,7 +214,7 @@ export class ReferenceResolver {
|
||||
public async deleteTarget(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IStoredRoute>,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const target = this.targets.get(id);
|
||||
if (!target) {
|
||||
@@ -263,7 +263,7 @@ export class ReferenceResolver {
|
||||
|
||||
public getTargetUsageForId(
|
||||
targetId: string,
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
@@ -334,20 +334,20 @@ export class ReferenceResolver {
|
||||
// =========================================================================
|
||||
|
||||
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||
.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[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
@@ -357,7 +357,7 @@ export class ReferenceResolver {
|
||||
return ids;
|
||||
}
|
||||
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.networkTargetRef === targetId) {
|
||||
@@ -547,7 +547,7 @@ export class ReferenceResolver {
|
||||
|
||||
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await StoredRouteDoc.findById(routeId);
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
@@ -562,7 +562,7 @@ export class ReferenceResolver {
|
||||
|
||||
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await StoredRouteDoc.findById(routeId);
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
|
||||
import { RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
IStoredRoute,
|
||||
IRouteOverride,
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
@@ -46,66 +45,58 @@ class RouteUpdateMutex {
|
||||
}
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
private overrides = new Map<string, IRouteOverride>();
|
||||
private routes = new Map<string, IRoute>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
private routeUpdateMutex = new RouteUpdateMutex();
|
||||
|
||||
constructor(
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||
) {}
|
||||
|
||||
/** Expose stored routes map for reference resolution lookups. */
|
||||
public getStoredRoutes(): Map<string, IStoredRoute> {
|
||||
return this.storedRoutes;
|
||||
/** Expose routes map for reference resolution lookups. */
|
||||
public getRoutes(): Map<string, IRoute> {
|
||||
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> {
|
||||
await this.loadStoredRoutes();
|
||||
await this.loadOverrides();
|
||||
public async initialize(
|
||||
configRoutes: IDcRouterRouteConfig[] = [],
|
||||
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.logWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Merged view
|
||||
// Route listing
|
||||
// =========================================================================
|
||||
|
||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||
const merged: IMergedRoute[] = [];
|
||||
|
||||
// Hardcoded routes
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
for (const route of this.routes.values()) {
|
||||
merged.push({
|
||||
route,
|
||||
source: 'hardcoded',
|
||||
enabled: override ? override.enabled : true,
|
||||
overridden: !!override,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
route: route.route,
|
||||
id: route.id,
|
||||
enabled: route.enabled,
|
||||
origin: route.origin,
|
||||
createdAt: route.createdAt,
|
||||
updatedAt: route.updatedAt,
|
||||
metadata: route.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +104,7 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Programmatic route CRUD
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
@@ -127,7 +118,7 @@ export class RouteConfigManager {
|
||||
|
||||
// Ensure route has a 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
|
||||
@@ -138,17 +129,18 @@ export class RouteConfigManager {
|
||||
resolvedMetadata = resolved.metadata;
|
||||
}
|
||||
|
||||
const stored: IStoredRoute = {
|
||||
const stored: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
origin: 'api',
|
||||
metadata: resolvedMetadata,
|
||||
};
|
||||
|
||||
this.storedRoutes.set(id, stored);
|
||||
this.routes.set(id, stored);
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return id;
|
||||
@@ -162,7 +154,7 @@ export class RouteConfigManager {
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const stored = this.storedRoutes.get(id);
|
||||
const stored = this.routes.get(id);
|
||||
if (!stored) return false;
|
||||
|
||||
if (patch.route) {
|
||||
@@ -201,9 +193,9 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
public async deleteRoute(id: string): Promise<boolean> {
|
||||
if (!this.storedRoutes.has(id)) return false;
|
||||
this.storedRoutes.delete(id);
|
||||
const doc = await StoredRouteDoc.findById(id);
|
||||
if (!this.routes.has(id)) return false;
|
||||
this.routes.delete(id);
|
||||
const doc = await RouteDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
await this.applyRoutes();
|
||||
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 = {
|
||||
routeName,
|
||||
enabled,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy,
|
||||
};
|
||||
this.overrides.set(routeName, override);
|
||||
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||
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();
|
||||
}
|
||||
/**
|
||||
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
|
||||
* Deletes stale DB routes whose origin matches but name is not in the seed set.
|
||||
*/
|
||||
private async seedRoutes(
|
||||
seedRoutes: IDcRouterRouteConfig[],
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
): Promise<void> {
|
||||
if (seedRoutes.length === 0) return;
|
||||
|
||||
public async removeOverride(routeName: string): Promise<boolean> {
|
||||
if (!this.overrides.has(routeName)) return false;
|
||||
this.overrides.delete(routeName);
|
||||
const doc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||
if (doc) await doc.delete();
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
const seedNames = new Set<string>();
|
||||
let seeded = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const route of seedRoutes) {
|
||||
const name = route.name || '';
|
||||
seedNames.add(name);
|
||||
|
||||
// Check if a route with this name+origin already exists in memory
|
||||
let existingId: string | undefined;
|
||||
for (const [id, r] of this.routes) {
|
||||
if (r.origin === origin && r.route.name === name) {
|
||||
existingId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (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 async loadStoredRoutes(): Promise<void> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
private async loadRoutes(): Promise<void> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
let prunedRuntimeRoutes = 0;
|
||||
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.storedRoutes.set(doc.id, {
|
||||
id: doc.id,
|
||||
route: doc.route,
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
metadata: doc.metadata,
|
||||
});
|
||||
if (!doc.id) continue;
|
||||
|
||||
const storedRoute: IRoute = {
|
||||
id: doc.id,
|
||||
route: doc.route,
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
origin: doc.origin || 'api',
|
||||
metadata: doc.metadata,
|
||||
};
|
||||
|
||||
if (this.isPersistedRuntimeRoute(storedRoute)) {
|
||||
await doc.delete();
|
||||
prunedRuntimeRoutes++;
|
||||
logger.log(
|
||||
'warn',
|
||||
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.routes.set(doc.id, storedRoute);
|
||||
}
|
||||
if (this.storedRoutes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||
if (this.routes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
||||
}
|
||||
if (prunedRuntimeRoutes > 0) {
|
||||
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOverrides(): Promise<void> {
|
||||
const docs = await RouteOverrideDoc.findAll();
|
||||
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);
|
||||
private async persistRoute(stored: IRoute): Promise<void> {
|
||||
const existingDoc = await RouteDoc.findById(stored.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.route = stored.route;
|
||||
existingDoc.enabled = stored.enabled;
|
||||
existingDoc.updatedAt = stored.updatedAt;
|
||||
existingDoc.createdBy = stored.createdBy;
|
||||
existingDoc.origin = stored.origin;
|
||||
existingDoc.metadata = stored.metadata;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new StoredRouteDoc();
|
||||
const doc = new RouteDoc();
|
||||
doc.id = stored.id;
|
||||
doc.route = stored.route;
|
||||
doc.enabled = stored.enabled;
|
||||
doc.createdAt = stored.createdAt;
|
||||
doc.updatedAt = stored.updatedAt;
|
||||
doc.createdBy = stored.createdBy;
|
||||
doc.origin = stored.origin;
|
||||
doc.metadata = stored.metadata;
|
||||
await doc.save();
|
||||
}
|
||||
@@ -322,33 +352,14 @@ export class RouteConfigManager {
|
||||
|
||||
private computeWarnings(): void {
|
||||
this.warnings = [];
|
||||
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||
|
||||
// Check overrides
|
||||
for (const [routeName, override] of this.overrides) {
|
||||
if (!hardcodedNames.has(routeName)) {
|
||||
for (const route of this.routes.values()) {
|
||||
if (!route.enabled) {
|
||||
const name = route.route.name || route.id;
|
||||
this.warnings.push({
|
||||
type: 'orphaned-override',
|
||||
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',
|
||||
type: 'disabled-route',
|
||||
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;
|
||||
|
||||
for (const routeId of routeIds) {
|
||||
const stored = this.storedRoutes.get(routeId);
|
||||
const stored = this.routes.get(routeId);
|
||||
if (!stored?.metadata) continue;
|
||||
|
||||
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> {
|
||||
@@ -397,54 +408,69 @@ export class RouteConfigManager {
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
|
||||
// Helper: inject VPN security into a vpnOnly route
|
||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||
if (!vpnCallback) return route;
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||
const existingEntries = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Add 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
|
||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config?.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route, stored.id));
|
||||
}
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the route set
|
||||
if (this.onRoutesApplied) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { logger } from '../logger.js';
|
||||
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
|
||||
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.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).
|
||||
@@ -13,6 +13,10 @@ import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'
|
||||
export class TargetProfileManager {
|
||||
private profiles = new Map<string, ITargetProfile>();
|
||||
|
||||
constructor(
|
||||
private getAllRoutes?: () => Map<string, IRoute>,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
@@ -43,13 +47,14 @@ export class TargetProfileManager {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||
const profile: ITargetProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
domains: data.domains,
|
||||
targets: data.targets,
|
||||
routeRefs: data.routeRefs,
|
||||
routeRefs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
@@ -70,11 +75,19 @@ export class TargetProfileManager {
|
||||
throw new Error(`Target profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined && patch.name !== profile.name) {
|
||||
for (const existing of this.profiles.values()) {
|
||||
if (existing.id !== id && existing.name === patch.name) {
|
||||
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
if (patch.description !== undefined) profile.description = patch.description;
|
||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
|
||||
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
@@ -127,6 +140,29 @@ export class TargetProfileManager {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize stored route references to route IDs when they can be resolved
|
||||
* uniquely against the current route registry.
|
||||
*/
|
||||
public async normalizeAllRouteRefs(): Promise<void> {
|
||||
const allRoutes = this.getAllRoutes?.();
|
||||
if (!allRoutes?.size) return;
|
||||
|
||||
for (const profile of this.profiles.values()) {
|
||||
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
||||
profile.routeRefs,
|
||||
allRoutes,
|
||||
'bestEffort',
|
||||
);
|
||||
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
||||
|
||||
profile.routeRefs = normalizedRouteRefs;
|
||||
profile.updatedAt = Date.now();
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
public listProfiles(): ITargetProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
@@ -178,9 +214,11 @@ export class TargetProfileManager {
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
clients: VpnClientDoc[],
|
||||
allRoutes: Map<string, IRoute> = new Map(),
|
||||
): Array<string | { ip: string; domains: string[] }> {
|
||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
@@ -194,7 +232,13 @@ export class TargetProfileManager {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) continue;
|
||||
|
||||
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
const matchResult = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
if (matchResult === 'full') {
|
||||
fullAccess = true;
|
||||
break; // No need to check more profiles
|
||||
@@ -220,11 +264,11 @@ export class TargetProfileManager {
|
||||
*/
|
||||
public getClientAccessSpec(
|
||||
targetProfileIds: string[],
|
||||
allRoutes: IDcRouterRouteConfig[],
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
allRoutes: Map<string, IRoute>,
|
||||
): { domains: string[]; targetIps: string[] } {
|
||||
const domains = new Set<string>();
|
||||
const targetIps = new Set<string>();
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
// Collect all access specifiers from assigned profiles
|
||||
for (const profileId of targetProfileIds) {
|
||||
@@ -245,23 +289,16 @@ export class TargetProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Route references: scan constructor routes
|
||||
for (const route of allRoutes) {
|
||||
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
|
||||
const routeDomains = (route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Route references: scan all routes
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
if (!route.enabled) continue;
|
||||
if (this.routeMatchesProfile(
|
||||
route.route as IDcRouterRouteConfig,
|
||||
routeId,
|
||||
profile,
|
||||
routeNameIndex,
|
||||
)) {
|
||||
const routeDomains = (route.route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
@@ -288,9 +325,16 @@ export class TargetProfileManager {
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): boolean {
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
const result = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
return result !== 'none';
|
||||
}
|
||||
|
||||
@@ -307,11 +351,17 @@ export class TargetProfileManager {
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeDomains: string[],
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||
// 1. Route reference match → full access
|
||||
if (profile.routeRefs?.length) {
|
||||
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
||||
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
|
||||
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
||||
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
||||
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Domain match
|
||||
@@ -375,6 +425,66 @@ export class TargetProfileManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||
}
|
||||
|
||||
private normalizeRouteRefsAgainstRoutes(
|
||||
routeRefs: string[] | undefined,
|
||||
allRoutes: Map<string, IRoute>,
|
||||
mode: 'strict' | 'bestEffort',
|
||||
): string[] | undefined {
|
||||
if (!routeRefs?.length) return undefined;
|
||||
if (!allRoutes.size) return [...new Set(routeRefs)];
|
||||
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
const normalizedRefs = new Set<string>();
|
||||
|
||||
for (const routeRef of routeRefs) {
|
||||
if (allRoutes.has(routeRef)) {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
||||
if (matchingRouteIds.length === 1) {
|
||||
normalizedRefs.add(matchingRouteIds[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode === 'bestEffort') {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchingRouteIds.length > 1) {
|
||||
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
||||
}
|
||||
throw new Error(`Route reference '${routeRef}' not found`);
|
||||
}
|
||||
|
||||
return [...normalizedRefs];
|
||||
}
|
||||
|
||||
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
||||
const routeNameIndex = new Map<string, string[]>();
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
const routeName = route.route.name;
|
||||
if (!routeName) continue;
|
||||
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
||||
matchingRouteIds.push(routeId);
|
||||
routeNameIndex.set(routeName, matchingRouteIds);
|
||||
}
|
||||
return routeNameIndex;
|
||||
}
|
||||
|
||||
private sameStringArray(left?: string[], right?: string[]): boolean {
|
||||
if (!left?.length && !right?.length) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
@@ -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({});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteing
|
||||
const getDb = () => DcRouterDb.getInstance().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.svDb()
|
||||
public id!: string;
|
||||
@@ -26,6 +26,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public origin!: 'config' | 'email' | 'dns' | 'api';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata?: IRouteMetadata;
|
||||
|
||||
@@ -33,11 +36,19 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<StoredRouteDoc | null> {
|
||||
return await StoredRouteDoc.getInstance({ id });
|
||||
public static async findById(id: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<StoredRouteDoc[]> {
|
||||
return await StoredRouteDoc.getInstances({});
|
||||
public static async findAll(): Promise<RouteDoc[]> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
|
||||
// Config document classes
|
||||
export * from './classes.stored-route.doc.js';
|
||||
export * from './classes.route-override.doc.js';
|
||||
export * from './classes.route.doc.js';
|
||||
export * from './classes.api-token.doc.js';
|
||||
export * from './classes.source-profile.doc.js';
|
||||
export * from './classes.target-profile.doc.js';
|
||||
|
||||
@@ -97,8 +97,8 @@ export class DnsManager {
|
||||
if (hasLegacyConfig) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
|
||||
'Manage DNS via the Domains UI instead.',
|
||||
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
|
||||
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -560,7 +560,7 @@ export class MetricsManager {
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [] as Array<any>,
|
||||
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number }>,
|
||||
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -720,11 +720,20 @@ export class MetricsManager {
|
||||
.slice(0, 10)
|
||||
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||
|
||||
// Build domain activity from per-route metrics
|
||||
// Build domain activity using per-IP domain request counts from Rust engine
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const throughputByRoute = proxyMetrics.throughput.byRoute();
|
||||
|
||||
// Map route name → ALL its domains (not just the first one)
|
||||
// Aggregate per-IP domain request counts into per-domain totals
|
||||
const domainRequestTotals = new Map<string, number>();
|
||||
const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP();
|
||||
for (const [, domainMap] of domainRequestsByIP) {
|
||||
for (const [domain, count] of domainMap) {
|
||||
domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count);
|
||||
}
|
||||
}
|
||||
|
||||
// Map route name → domains from route config
|
||||
const routeDomains = new Map<string, string[]>();
|
||||
if (this.dcRouter.smartProxy) {
|
||||
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
||||
@@ -738,34 +747,26 @@ export class MetricsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Use protocol cache to discover actual active domains (resolves wildcards)
|
||||
const activeDomains = new Set<string>();
|
||||
const domainToBackend = new Map<string, string>(); // domain → host:port
|
||||
// Resolve wildcards using domains seen in request metrics
|
||||
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
|
||||
for (const entry of protocolCache) {
|
||||
if (entry.domain) {
|
||||
activeDomains.add(entry.domain);
|
||||
domainToBackend.set(entry.domain, `${entry.host}:${entry.port}`);
|
||||
}
|
||||
if (entry.domain) allKnownDomains.add(entry.domain);
|
||||
}
|
||||
|
||||
// Build reverse map: domain → route name(s) that handle it
|
||||
// For concrete domains: direct lookup from route config
|
||||
// For wildcard patterns: match active domains from protocol cache
|
||||
// Build reverse map: concrete domain → route name(s)
|
||||
const domainToRoutes = new Map<string, string[]>();
|
||||
for (const [routeName, domains] of routeDomains) {
|
||||
for (const pattern of domains) {
|
||||
if (pattern.includes('*')) {
|
||||
// Wildcard pattern — match against active domains from protocol cache
|
||||
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
||||
for (const activeDomain of activeDomains) {
|
||||
if (regex.test(activeDomain)) {
|
||||
const existing = domainToRoutes.get(activeDomain);
|
||||
for (const knownDomain of allKnownDomains) {
|
||||
if (regex.test(knownDomain)) {
|
||||
const existing = domainToRoutes.get(knownDomain);
|
||||
if (existing) { existing.push(routeName); }
|
||||
else { domainToRoutes.set(activeDomain, [routeName]); }
|
||||
else { domainToRoutes.set(knownDomain, [routeName]); }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Concrete domain
|
||||
const existing = domainToRoutes.get(pattern);
|
||||
if (existing) { existing.push(routeName); }
|
||||
else { domainToRoutes.set(pattern, [routeName]); }
|
||||
@@ -773,37 +774,40 @@ export class MetricsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate metrics per domain
|
||||
// For each domain, sum metrics from all routes that serve it,
|
||||
// divided by the number of domains each route serves
|
||||
// For each route, compute the total request count across all its resolved domains
|
||||
// so we can distribute throughput/connections proportionally
|
||||
const routeTotalRequests = new Map<string, number>();
|
||||
for (const [domain, routeNames] of domainToRoutes) {
|
||||
const reqs = domainRequestTotals.get(domain) || 0;
|
||||
for (const routeName of routeNames) {
|
||||
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 0) + reqs);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate metrics per domain using request-count-proportional splitting
|
||||
const domainAgg = new Map<string, {
|
||||
activeConnections: number;
|
||||
bytesInPerSec: number;
|
||||
bytesOutPerSec: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
}>();
|
||||
|
||||
// Track which routes are accounted for
|
||||
const accountedRoutes = new Set<string>();
|
||||
|
||||
for (const [domain, routeNames] of domainToRoutes) {
|
||||
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||
let totalConns = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
|
||||
for (const routeName of routeNames) {
|
||||
accountedRoutes.add(routeName);
|
||||
const conns = connectionsByRoute.get(routeName) || 0;
|
||||
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
||||
// Count how many resolved domains share this route
|
||||
let domainsInRoute = 0;
|
||||
for (const [, routes] of domainToRoutes) {
|
||||
if (routes.includes(routeName)) domainsInRoute++;
|
||||
}
|
||||
const share = Math.max(domainsInRoute, 1);
|
||||
totalConns += conns / share;
|
||||
totalIn += tp.in / share;
|
||||
totalOut += tp.out / share;
|
||||
const routeTotal = routeTotalRequests.get(routeName) || 0;
|
||||
|
||||
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
||||
totalConns += conns * share;
|
||||
totalIn += tp.in * share;
|
||||
totalOut += tp.out * share;
|
||||
}
|
||||
|
||||
domainAgg.set(domain, {
|
||||
@@ -811,31 +815,10 @@ export class MetricsManager {
|
||||
bytesInPerSec: totalIn,
|
||||
bytesOutPerSec: totalOut,
|
||||
routeCount: routeNames.length,
|
||||
requestCount: domainReqs,
|
||||
});
|
||||
}
|
||||
|
||||
// Include routes with no domain config (fallback: use route name)
|
||||
for (const [routeName, activeConns] of connectionsByRoute) {
|
||||
if (accountedRoutes.has(routeName)) continue;
|
||||
if (routeDomains.has(routeName)) continue; // has domains but no traffic matched
|
||||
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
||||
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
|
||||
const existing = domainAgg.get(routeName);
|
||||
if (existing) {
|
||||
existing.activeConnections += activeConns;
|
||||
existing.bytesInPerSec += tp.in;
|
||||
existing.bytesOutPerSec += tp.out;
|
||||
existing.routeCount++;
|
||||
} else {
|
||||
domainAgg.set(routeName, {
|
||||
activeConnections: activeConns,
|
||||
bytesInPerSec: tp.in,
|
||||
bytesOutPerSec: tp.out,
|
||||
routeCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const domainActivity = Array.from(domainAgg.entries())
|
||||
.map(([domain, data]) => ({
|
||||
domain,
|
||||
@@ -843,6 +826,7 @@ export class MetricsManager {
|
||||
bytesOutPerSecond: data.bytesOutPerSec,
|
||||
activeConnections: data.activeConnections,
|
||||
routeCount: data.routeCount,
|
||||
requestCount: data.requestCount,
|
||||
}))
|
||||
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
||||
|
||||
|
||||
@@ -198,12 +198,11 @@ export class CertificateHandler {
|
||||
try {
|
||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||
if (rustStatus) {
|
||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
||||
status = rustStatus.status;
|
||||
if (rustStatus.expiresAt > 0) {
|
||||
expiryDate = new Date(rustStatus.expiresAt).toISOString();
|
||||
}
|
||||
if (rustStatus.source) issuer = rustStatus.source;
|
||||
status = rustStatus.isValid ? 'valid' : 'expired';
|
||||
}
|
||||
} catch {
|
||||
// Rust bridge may not support this command yet — ignore
|
||||
|
||||
@@ -135,7 +135,7 @@ export class NetworkTargetHandler {
|
||||
const result = await resolver.deleteTarget(
|
||||
dataArg.id,
|
||||
dataArg.force ?? false,
|
||||
manager.getStoredRoutes(),
|
||||
manager.getRoutes(),
|
||||
);
|
||||
|
||||
if (result.success && dataArg.force) {
|
||||
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
|
||||
if (!resolver || !manager) {
|
||||
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 })) };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -72,7 +72,7 @@ export class RouteManagementHandler {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
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
|
||||
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
|
||||
// Toggle route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
|
||||
@@ -136,7 +136,7 @@ export class SourceProfileHandler {
|
||||
const result = await resolver.deleteProfile(
|
||||
dataArg.id,
|
||||
dataArg.force ?? false,
|
||||
manager.getStoredRoutes(),
|
||||
manager.getRoutes(),
|
||||
);
|
||||
|
||||
// If force-deleted with affected routes, re-apply
|
||||
@@ -160,7 +160,7 @@ export class SourceProfileHandler {
|
||||
if (!resolver || !manager) {
|
||||
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 })) };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -55,6 +55,8 @@ export class VpnManager {
|
||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||
private clients: Map<string, VpnClientDoc> = new Map();
|
||||
private serverKeys?: VpnServerKeysDoc;
|
||||
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||
|
||||
constructor(config: IVpnManagerConfig) {
|
||||
this.config = config;
|
||||
@@ -88,6 +90,7 @@ export class VpnManager {
|
||||
if (client.useHostIp) {
|
||||
anyClientUsesHostIp = true;
|
||||
}
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
const entry: plugins.smartvpn.IClientEntry = {
|
||||
clientId: client.clientId,
|
||||
publicKey: client.noisePublicKey,
|
||||
@@ -97,13 +100,12 @@ export class VpnManager {
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
security: this.buildClientSecurity(client),
|
||||
useHostIp: client.useHostIp,
|
||||
useDhcp: client.useDhcp,
|
||||
staticIp: client.staticIp,
|
||||
forceVlan: client.forceVlan,
|
||||
vlanId: client.vlanId,
|
||||
};
|
||||
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
||||
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
||||
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
||||
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
||||
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
||||
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
||||
clientEntries.push(entry);
|
||||
}
|
||||
|
||||
@@ -112,13 +114,15 @@ export class VpnManager {
|
||||
|
||||
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||
let configuredMode = this.config.forwardingMode ?? 'socket';
|
||||
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||
configuredMode = 'hybrid';
|
||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||
}
|
||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||
const isBridge = forwardingMode === 'bridge';
|
||||
this.resolvedForwardingMode = forwardingMode;
|
||||
this.forwardingModeOverride = undefined;
|
||||
|
||||
// Create and start VpnServer
|
||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||
@@ -143,7 +147,7 @@ export class VpnManager {
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
@@ -189,6 +193,7 @@ export class VpnManager {
|
||||
this.vpnServer.stop();
|
||||
this.vpnServer = undefined;
|
||||
}
|
||||
this.resolvedForwardingMode = undefined;
|
||||
logger.log('info', 'VPN server stopped');
|
||||
}
|
||||
|
||||
@@ -213,14 +218,38 @@ export class VpnManager {
|
||||
throw new Error('VPN server not running');
|
||||
}
|
||||
|
||||
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
|
||||
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = opts.clientId;
|
||||
doc.enabled = true;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = opts.description;
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
doc.destinationBlockList = opts.destinationBlockList;
|
||||
doc.useHostIp = opts.useHostIp;
|
||||
doc.useDhcp = opts.useDhcp;
|
||||
doc.staticIp = opts.staticIp;
|
||||
doc.forceVlan = opts.forceVlan;
|
||||
doc.vlanId = opts.vlanId;
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
|
||||
const bundle = await this.vpnServer.createClient({
|
||||
clientId: opts.clientId,
|
||||
description: opts.description,
|
||||
clientId: doc.clientId,
|
||||
description: doc.description,
|
||||
security: this.buildClientSecurity(doc),
|
||||
useHostIp: doc.useHostIp,
|
||||
useDhcp: doc.useDhcp,
|
||||
staticIp: doc.staticIp,
|
||||
forceVlan: doc.forceVlan,
|
||||
vlanId: doc.vlanId,
|
||||
});
|
||||
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
@@ -228,40 +257,16 @@ export class VpnManager {
|
||||
}
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = bundle.entry.clientId;
|
||||
doc.enabled = bundle.entry.enabled ?? true;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = bundle.entry.description;
|
||||
doc.assignedIp = bundle.entry.assignedIp;
|
||||
doc.noisePublicKey = bundle.entry.publicKey;
|
||||
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
doc.expiresAt = bundle.entry.expiresAt;
|
||||
if (opts.destinationAllowList !== undefined) {
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
}
|
||||
if (opts.destinationBlockList !== undefined) {
|
||||
doc.destinationBlockList = opts.destinationBlockList;
|
||||
}
|
||||
if (opts.useHostIp !== undefined) {
|
||||
doc.useHostIp = opts.useHostIp;
|
||||
}
|
||||
if (opts.useDhcp !== undefined) {
|
||||
doc.useDhcp = opts.useDhcp;
|
||||
}
|
||||
if (opts.staticIp !== undefined) {
|
||||
doc.staticIp = opts.staticIp;
|
||||
}
|
||||
if (opts.forceVlan !== undefined) {
|
||||
doc.forceVlan = opts.forceVlan;
|
||||
}
|
||||
if (opts.vlanId !== undefined) {
|
||||
doc.vlanId = opts.vlanId;
|
||||
}
|
||||
this.clients.set(doc.clientId, doc);
|
||||
try {
|
||||
await this.persistClient(doc);
|
||||
@@ -276,12 +281,6 @@ export class VpnManager {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
const security = this.buildClientSecurity(doc);
|
||||
if (security.destinationPolicy) {
|
||||
await this.vpnServer!.updateClient(doc.clientId, { security });
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
return bundle;
|
||||
}
|
||||
@@ -364,13 +363,13 @@ export class VpnManager {
|
||||
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
if (this.vpnServer) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
await this.vpnServer.updateClient(clientId, { security });
|
||||
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
@@ -478,26 +477,28 @@ export class VpnManager {
|
||||
|
||||
/**
|
||||
* Build per-client security settings for the smartvpn daemon.
|
||||
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
|
||||
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
||||
* TargetProfile direct IP:port targets extend the effective allow-list.
|
||||
*/
|
||||
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||
const security: plugins.smartvpn.IClientSecurity = {};
|
||||
const basePolicy = this.getBaseDestinationPolicy(client);
|
||||
|
||||
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
|
||||
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||
|
||||
// Merge with per-client explicit allow list
|
||||
const mergedAllowList = [
|
||||
...(client.destinationAllowList || []),
|
||||
...profileDirectTargets,
|
||||
];
|
||||
const mergedAllowList = this.mergeDestinationLists(
|
||||
basePolicy.allowList,
|
||||
client.destinationAllowList,
|
||||
profileDirectTargets,
|
||||
);
|
||||
const mergedBlockList = this.mergeDestinationLists(
|
||||
basePolicy.blockList,
|
||||
client.destinationBlockList,
|
||||
);
|
||||
|
||||
security.destinationPolicy = {
|
||||
default: 'forceTarget' as const,
|
||||
target: '127.0.0.1',
|
||||
default: basePolicy.default,
|
||||
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||
blockList: client.destinationBlockList,
|
||||
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
||||
};
|
||||
|
||||
return security;
|
||||
@@ -510,10 +511,7 @@ export class VpnManager {
|
||||
public async refreshAllClientSecurity(): Promise<void> {
|
||||
if (!this.vpnServer) return;
|
||||
for (const client of this.clients.values()) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
if (security.destinationPolicy) {
|
||||
await this.vpnServer.updateClient(client.clientId, { security });
|
||||
}
|
||||
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +548,7 @@ export class VpnManager {
|
||||
private async loadPersistedClients(): Promise<void> {
|
||||
const docs = await VpnClientDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
this.clients.set(doc.clientId, doc);
|
||||
}
|
||||
if (this.clients.size > 0) {
|
||||
@@ -557,6 +556,93 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
||||
return this.resolvedForwardingMode
|
||||
?? this.forwardingModeOverride
|
||||
?? this.config.forwardingMode
|
||||
?? 'socket';
|
||||
}
|
||||
|
||||
private getDefaultDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
useHostIp = false,
|
||||
): plugins.smartvpn.IDestinationPolicy {
|
||||
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
|
||||
return { default: 'allow' };
|
||||
}
|
||||
return { default: 'forceTarget', target: '127.0.0.1' };
|
||||
}
|
||||
|
||||
private getServerDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
|
||||
): plugins.smartvpn.IDestinationPolicy {
|
||||
return this.config.destinationPolicy ?? fallbackPolicy;
|
||||
}
|
||||
|
||||
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
|
||||
if (this.config.destinationPolicy) {
|
||||
return { ...this.config.destinationPolicy };
|
||||
}
|
||||
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
|
||||
}
|
||||
|
||||
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
|
||||
const merged = new Set<string>();
|
||||
for (const list of lists) {
|
||||
for (const entry of list || []) {
|
||||
merged.add(entry);
|
||||
}
|
||||
}
|
||||
return [...merged];
|
||||
}
|
||||
|
||||
private normalizeClientRoutingSettings(
|
||||
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
|
||||
): void {
|
||||
client.useHostIp = client.useHostIp === true;
|
||||
|
||||
if (!client.useHostIp) {
|
||||
client.useDhcp = false;
|
||||
client.staticIp = undefined;
|
||||
client.forceVlan = false;
|
||||
client.vlanId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
client.useDhcp = client.useDhcp === true;
|
||||
if (client.useDhcp) {
|
||||
client.staticIp = undefined;
|
||||
}
|
||||
|
||||
client.forceVlan = client.forceVlan === true;
|
||||
if (!client.forceVlan) {
|
||||
client.vlanId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
|
||||
return {
|
||||
description: client.description,
|
||||
security: this.buildClientSecurity(client),
|
||||
useHostIp: client.useHostIp,
|
||||
useDhcp: client.useDhcp,
|
||||
staticIp: client.staticIp,
|
||||
forceVlan: client.forceVlan,
|
||||
vlanId: client.vlanId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
|
||||
if (!useHostIp || !this.vpnServer) return;
|
||||
if (this.getResolvedForwardingMode() !== 'socket') return;
|
||||
|
||||
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
|
||||
this.forwardingModeOverride = 'hybrid';
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||
await client.save();
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ export class Route {
|
||||
|
||||
// Data from IMergedRoute
|
||||
public routeConfig: IRouteConfig;
|
||||
public source: 'hardcoded' | 'programmatic';
|
||||
public id: string;
|
||||
public enabled: boolean;
|
||||
public overridden: boolean;
|
||||
public storedRouteId?: string;
|
||||
public origin: 'config' | 'email' | 'dns' | 'api';
|
||||
public createdAt?: number;
|
||||
public updatedAt?: number;
|
||||
|
||||
@@ -22,21 +21,17 @@ export class Route {
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
|
||||
this.clientRef = clientRef;
|
||||
this.routeConfig = data.route;
|
||||
this.source = data.source;
|
||||
this.id = data.id;
|
||||
this.enabled = data.enabled;
|
||||
this.overridden = data.overridden;
|
||||
this.storedRouteId = data.storedRouteId;
|
||||
this.origin = data.origin;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
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>(
|
||||
'updateRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to update route');
|
||||
@@ -44,12 +39,9 @@ export class Route {
|
||||
}
|
||||
|
||||
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>(
|
||||
'deleteRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete route');
|
||||
@@ -57,41 +49,15 @@ export class Route {
|
||||
}
|
||||
|
||||
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>(
|
||||
'toggleRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to toggle route');
|
||||
}
|
||||
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 {
|
||||
@@ -144,9 +110,8 @@ export class RouteBuilder {
|
||||
}
|
||||
|
||||
// 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 created = routes.find((r) => r.storedRouteId === response.storedRouteId);
|
||||
const created = routes.find((r) => r.id === response.routeId);
|
||||
if (created) {
|
||||
return created;
|
||||
}
|
||||
@@ -154,10 +119,9 @@ export class RouteBuilder {
|
||||
// Fallback: construct from known data
|
||||
return new Route(this.clientRef, {
|
||||
route: this.routeConfig as IRouteConfig,
|
||||
source: 'programmatic',
|
||||
id: response.routeId || '',
|
||||
enabled: this.isEnabled,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
origin: 'api',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -190,10 +154,9 @@ export class RouteManager {
|
||||
}
|
||||
return new Route(this.clientRef, {
|
||||
route: routeConfig,
|
||||
source: 'programmatic',
|
||||
id: response.routeId || '',
|
||||
enabled: enabled ?? true,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
origin: 'api',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
route: IDcRouterRouteConfig;
|
||||
source: 'hardcoded' | 'programmatic';
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
overridden: boolean;
|
||||
storedRouteId?: string;
|
||||
origin: 'config' | 'email' | 'dns' | 'api';
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
metadata?: IRouteMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning generated during route merge/startup.
|
||||
* A warning generated during route startup/apply.
|
||||
*/
|
||||
export interface IRouteWarning {
|
||||
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
|
||||
type: 'disabled-route';
|
||||
routeName: 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;
|
||||
route: IDcRouterRouteConfig;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
origin: 'config' | 'email' | 'dns' | 'api';
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface IDomainActivity {
|
||||
bytesOutPerSecond: number;
|
||||
activeConnections: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface INetworkMetrics {
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ITargetProfile {
|
||||
domains?: string[];
|
||||
/** Specific IP:port targets this profile grants access to */
|
||||
targets?: ITargetProfileTarget[];
|
||||
/** Route references by stored route ID or route name */
|
||||
/** Route references by stored route ID. Legacy route names are normalized when unique. */
|
||||
routeRefs?: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
|
||||
@@ -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<
|
||||
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<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
storedRouteId?: string;
|
||||
routeId?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a programmatic route.
|
||||
* Update a route.
|
||||
*/
|
||||
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
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<
|
||||
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).
|
||||
*/
|
||||
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.
|
||||
* Toggle a route on/off by id.
|
||||
*/
|
||||
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
|
||||
@@ -21,6 +21,30 @@ export interface IMigrationRunner {
|
||||
run(): Promise<IMigrationRunResult>;
|
||||
}
|
||||
|
||||
async function migrateTargetProfileTargetHosts(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('TargetProfileDoc');
|
||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||
let migrated = 0;
|
||||
|
||||
for await (const doc of cursor) {
|
||||
const targets = ((doc as any).targets || []).map((target: any) => {
|
||||
if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) {
|
||||
const { host, ...rest } = target;
|
||||
return { ...rest, ip: host };
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -48,23 +72,7 @@ export async function createMigrationRunner(
|
||||
.step('rename-target-profile-host-to-ip')
|
||||
.from('13.0.11').to('13.1.0')
|
||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('targetprofiledoc');
|
||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||
let migrated = 0;
|
||||
for await (const doc of cursor) {
|
||||
const targets = ((doc as any).targets || []).map((t: any) => {
|
||||
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
|
||||
const { host, ...rest } = t;
|
||||
return { ...rest, ip: host };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||
migrated++;
|
||||
}
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
})
|
||||
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
|
||||
.step('rename-domain-source-manual-to-dcrouter')
|
||||
.from('13.1.0').to('13.8.1')
|
||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||
@@ -92,6 +100,40 @@ export async function createMigrationRunner(
|
||||
'info',
|
||||
`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;
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.15.1',
|
||||
version: '13.17.8',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -2219,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
|
||||
// ============================================================================
|
||||
|
||||
@@ -560,11 +560,12 @@ export class OpsViewNetworkActivity extends DeesElement {
|
||||
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
|
||||
'Transferred / min': this.formatBytes(totalBytesPerMin),
|
||||
'Connections': item.activeConnections,
|
||||
'Requests': item.requestCount?.toLocaleString() ?? '0',
|
||||
'Routes': item.routeCount,
|
||||
};
|
||||
}}
|
||||
heading1="Domain Activity"
|
||||
heading2="Per-domain network activity aggregated from route metrics"
|
||||
heading2="Per-domain network activity from request-level metrics"
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
.pagination=${false}
|
||||
|
||||
@@ -49,6 +49,8 @@ function setupTlsVisibility(formEl: any) {
|
||||
|
||||
@customElement('ops-view-routes')
|
||||
export class OpsViewRoutes extends DeesElement {
|
||||
@state() accessor routeFilter: 'User Routes' | 'System Routes' = 'User Routes';
|
||||
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
@@ -140,9 +142,9 @@ export class OpsViewRoutes extends DeesElement {
|
||||
public render(): TemplateResult {
|
||||
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 configCount = mergedRoutes.filter((mr) => mr.origin !== 'api').length;
|
||||
const apiCount = mergedRoutes.filter((mr) => mr.origin === 'api').length;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
@@ -155,21 +157,21 @@ export class OpsViewRoutes extends DeesElement {
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'hardcoded',
|
||||
title: 'Hardcoded',
|
||||
id: 'configRoutes',
|
||||
title: 'System Routes',
|
||||
type: 'number',
|
||||
value: hardcodedCount,
|
||||
icon: 'lucide:lock',
|
||||
description: 'Routes from constructor config',
|
||||
value: configCount,
|
||||
icon: 'lucide:settings',
|
||||
description: 'From config, email, and DNS',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'programmatic',
|
||||
title: 'Programmatic',
|
||||
id: 'apiRoutes',
|
||||
title: 'User Routes',
|
||||
type: 'number',
|
||||
value: programmaticCount,
|
||||
value: apiCount,
|
||||
icon: 'lucide:code',
|
||||
description: 'Routes added via API',
|
||||
description: 'Created via API',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
{
|
||||
@@ -183,18 +185,23 @@ export class OpsViewRoutes extends DeesElement {
|
||||
},
|
||||
];
|
||||
|
||||
// Map merged routes to sz-route-list-view format
|
||||
const szRoutes = mergedRoutes.map((mr) => {
|
||||
// Filter routes based on selected tab
|
||||
const isUserRoutes = this.routeFilter === 'User Routes';
|
||||
const filteredRoutes = mergedRoutes.filter((mr) =>
|
||||
isUserRoutes ? mr.origin === 'api' : mr.origin !== 'api'
|
||||
);
|
||||
|
||||
// Map filtered routes to sz-route-list-view format
|
||||
const szRoutes = filteredRoutes.map((mr) => {
|
||||
const tags = [...(mr.route.tags || [])];
|
||||
tags.push(mr.source);
|
||||
tags.push(mr.origin);
|
||||
if (!mr.enabled) tags.push('disabled');
|
||||
if (mr.overridden) tags.push('overridden');
|
||||
|
||||
return {
|
||||
...mr.route,
|
||||
enabled: mr.enabled,
|
||||
tags,
|
||||
id: mr.storedRouteId || mr.route.name || undefined,
|
||||
id: mr.id || mr.route.name || undefined,
|
||||
metadata: mr.metadata,
|
||||
};
|
||||
});
|
||||
@@ -219,6 +226,13 @@ export class OpsViewRoutes extends DeesElement {
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
|
||||
<dees-input-multitoggle
|
||||
class="routeFilterToggle"
|
||||
.type=${'single'}
|
||||
.options=${['User Routes', 'System Routes']}
|
||||
.selectedOption=${this.routeFilter}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
${warnings.length > 0
|
||||
? html`
|
||||
<div class="warnings-bar">
|
||||
@@ -238,7 +252,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
? html`
|
||||
<sz-route-list-view
|
||||
.routes=${szRoutes}
|
||||
.showActionsFilter=${(route: any) => route.tags?.includes('programmatic') ?? false}
|
||||
.showActionsFilter=${isUserRoutes ? () => true : () => false}
|
||||
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
||||
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
|
||||
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
|
||||
@@ -246,8 +260,8 @@ export class OpsViewRoutes extends DeesElement {
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<p>No routes configured</p>
|
||||
<p>Add a programmatic route or check your constructor configuration.</p>
|
||||
<p>No ${isUserRoutes ? 'user' : 'system'} routes</p>
|
||||
<p>${isUserRoutes ? 'Add a route to get started.' : 'System routes are generated from config, email, and DNS settings.'}</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
@@ -266,112 +280,56 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
if (merged.source === 'hardcoded') {
|
||||
const menuOptions = merged.enabled
|
||||
? [
|
||||
{
|
||||
name: 'Disable Route',
|
||||
iconName: 'lucide:pause',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: false },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => 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();
|
||||
},
|
||||
const meta = merged.metadata;
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||
<p>ID: <code style="color: #888;">${merged.id}</code></p>
|
||||
${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.id, enabled: !merged.enabled },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.deleteRouteAction,
|
||||
merged.storedRouteId!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
action: async (modalArg: any) => {
|
||||
await modalArg.destroy();
|
||||
this.showEditRouteDialog(merged);
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.deleteRouteAction,
|
||||
merged.id,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRouteEdit(e: CustomEvent) {
|
||||
@@ -381,7 +339,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const merged = this.routeState.mergedRoutes.find(
|
||||
(mr) => mr.route.name === clickedRoute.name,
|
||||
);
|
||||
if (!merged || !merged.storedRouteId) return;
|
||||
if (!merged) return;
|
||||
|
||||
this.showEditRouteDialog(merged);
|
||||
}
|
||||
@@ -393,7 +351,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const merged = this.routeState.mergedRoutes.find(
|
||||
(mr) => mr.route.name === clickedRoute.name,
|
||||
);
|
||||
if (!merged || !merged.storedRouteId) return;
|
||||
if (!merged) return;
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
@@ -415,7 +373,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.deleteRouteAction,
|
||||
merged.storedRouteId!,
|
||||
merged.id,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
@@ -563,7 +521,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.updateRouteAction,
|
||||
{
|
||||
id: merged.storedRouteId!,
|
||||
id: merged.id,
|
||||
route: updatedRoute,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
},
|
||||
@@ -603,7 +561,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
];
|
||||
|
||||
const createModal = await DeesModal.createAndShow({
|
||||
heading: 'Add Programmatic Route',
|
||||
heading: 'Add Route',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
||||
@@ -719,5 +677,13 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
|
||||
const toggle = this.shadowRoot!.querySelector('.routeFilterToggle') as any;
|
||||
if (toggle) {
|
||||
const sub = toggle.changeSubject.subscribe(() => {
|
||||
this.routeFilter = toggle.selectedOption;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
||||
: '-',
|
||||
'Route Refs': profile.routeRefs?.length
|
||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
|
||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
|
||||
: '-',
|
||||
Created: new Date(profile.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
@@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private getRouteCandidates() {
|
||||
private getRouteChoices() {
|
||||
const routeState = appstate.routeManagementStatePart.getState();
|
||||
const routes = routeState?.mergedRoutes || [];
|
||||
return routes
|
||||
.filter((mr) => mr.route.name)
|
||||
.map((mr) => ({ viewKey: mr.route.name! }));
|
||||
.filter((mr) => mr.route.name && mr.id)
|
||||
.map((mr) => ({
|
||||
routeId: mr.id!,
|
||||
routeName: mr.route.name!,
|
||||
label: `${mr.route.name} (${mr.id})`,
|
||||
}));
|
||||
}
|
||||
|
||||
private getRouteCandidates() {
|
||||
return this.getRouteChoices().map((route) => ({ viewKey: route.label }));
|
||||
}
|
||||
|
||||
private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined {
|
||||
if (!routeRefs?.length) return undefined;
|
||||
|
||||
const routeChoices = this.getRouteChoices();
|
||||
const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label]));
|
||||
const routeByName = new Map<string, string[]>();
|
||||
|
||||
for (const route of routeChoices) {
|
||||
const labels = routeByName.get(route.routeName) || [];
|
||||
labels.push(route.label);
|
||||
routeByName.set(route.routeName, labels);
|
||||
}
|
||||
|
||||
return routeRefs.map((routeRef) => {
|
||||
const routeLabel = routeById.get(routeRef);
|
||||
if (routeLabel) return routeLabel;
|
||||
|
||||
const labelsForName = routeByName.get(routeRef) || [];
|
||||
if (labelsForName.length === 1) return labelsForName[0];
|
||||
|
||||
return routeRef;
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRouteLabelsToRefs(routeRefs: string[]): string[] {
|
||||
if (!routeRefs.length) return [];
|
||||
|
||||
const labelToId = new Map(
|
||||
this.getRouteChoices().map((route) => [route.label, route.routeId]),
|
||||
);
|
||||
return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef);
|
||||
}
|
||||
|
||||
private formatRouteRef(routeRef: string): string {
|
||||
return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef;
|
||||
}
|
||||
|
||||
private async ensureRoutesLoaded() {
|
||||
@@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
};
|
||||
})
|
||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
||||
const routeRefs = this.resolveRouteLabelsToRefs(
|
||||
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
||||
);
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
||||
name: String(data.name),
|
||||
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||
const currentDomains = profile.domains || [];
|
||||
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
||||
const currentRouteRefs = profile.routeRefs || [];
|
||||
const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || [];
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await this.ensureRoutesLoaded();
|
||||
@@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
};
|
||||
})
|
||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
||||
const routeRefs = this.resolveRouteLabelsToRefs(
|
||||
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
||||
);
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
||||
id: profile.id,
|
||||
@@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">
|
||||
${profile.routeRefs?.length
|
||||
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
|
||||
? profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
|
||||
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
||||
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
||||
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
|
||||
if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
|
||||
if (hostIpGroup) hostIpGroup.style.display = show;
|
||||
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
|
||||
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
||||
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
||||
@@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
if (!data.clientId) return;
|
||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
||||
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||
);
|
||||
|
||||
@@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
|
||||
description: data.description || undefined,
|
||||
targetProfileIds,
|
||||
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
useHostIp,
|
||||
useDhcp,
|
||||
staticIp,
|
||||
forceVlan: forceVlan || undefined,
|
||||
forceVlan,
|
||||
vlanId,
|
||||
destinationAllowList,
|
||||
destinationBlockList,
|
||||
@@ -485,7 +485,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
||||
` : ''}
|
||||
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
|
||||
${client.useHostIp ? html`
|
||||
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
|
||||
@@ -649,7 +649,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const currentDescription = client.description ?? '';
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
|
||||
const profileCandidates = this.getTargetProfileCandidates();
|
||||
const currentUseHostIp = client.useHostIp ?? false;
|
||||
const currentUseDhcp = client.useDhcp ?? false;
|
||||
@@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
||||
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||
);
|
||||
|
||||
@@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
|
||||
description: data.description || undefined,
|
||||
targetProfileIds,
|
||||
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
useHostIp,
|
||||
useDhcp,
|
||||
staticIp,
|
||||
forceVlan: forceVlan || undefined,
|
||||
forceVlan,
|
||||
vlanId,
|
||||
destinationAllowList,
|
||||
destinationBlockList,
|
||||
@@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build autocomplete candidates from loaded target profiles.
|
||||
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
|
||||
* Build stable profile labels for list inputs.
|
||||
*/
|
||||
private getTargetProfileCandidates() {
|
||||
private getTargetProfileChoices() {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
|
||||
const nameCounts = new Map<string, number>();
|
||||
|
||||
for (const profile of profiles) {
|
||||
nameCounts.set(profile.name, (nameCounts.get(profile.name) || 0) + 1);
|
||||
}
|
||||
|
||||
return profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: (nameCounts.get(profile.name) || 0) > 1
|
||||
? `${profile.name} (${profile.id})`
|
||||
: profile.name,
|
||||
}));
|
||||
}
|
||||
|
||||
private getTargetProfileCandidates() {
|
||||
return this.getTargetProfileChoices().map((profile) => ({ viewKey: profile.label }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert profile IDs to profile names (for populating edit form values).
|
||||
* Convert profile IDs to form labels (for populating edit form values).
|
||||
*/
|
||||
private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
|
||||
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
|
||||
if (!ids?.length) return undefined;
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
const choices = this.getTargetProfileChoices();
|
||||
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
|
||||
return ids.map((id) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
return profile?.name || id;
|
||||
return labelsById.get(id) || id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert profile names back to IDs (for saving form data).
|
||||
* Uses the dees-input-list candidates' payload when available.
|
||||
* Convert profile form labels back to IDs.
|
||||
*/
|
||||
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
|
||||
if (!names.length) return undefined;
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
return names
|
||||
.map((name) => {
|
||||
const profile = profiles.find((p) => p.name === name);
|
||||
return profile?.id;
|
||||
})
|
||||
private resolveProfileLabelsToIds(labels: string[]): string[] {
|
||||
if (!labels.length) return [];
|
||||
|
||||
const labelsToIds = new Map(
|
||||
this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
|
||||
);
|
||||
return labels
|
||||
.map((label) => labelsToIds.get(label))
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user