Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a378ae87f | |||
| 58fbc2b1e4 | |||
| 20ea0ce683 | |||
| bcea93753b | |||
| 848515e424 | |||
| 38c9978969 | |||
| ee863b8178 | |||
| 9bb5a8bcc1 | |||
| 5aa07e81c7 | |||
| aec8b72ca3 |
36
changelog.md
36
changelog.md
@@ -1,5 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.17.9 - fix(monitoring)
|
||||||
|
align domain activity metrics with id-keyed route data
|
||||||
|
|
||||||
|
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
|
||||||
|
- Add a regression test covering domain activity aggregation for routes identified only by id.
|
||||||
|
- Update the network activity UI to show formatted total connection counts in the active connections card.
|
||||||
|
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.17.8 - fix(opsserver)
|
||||||
|
align certificate status handling with the updated smartproxy response format
|
||||||
|
|
||||||
|
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
|
||||||
|
- bump @push.rocks/smartproxy to ^27.7.3
|
||||||
|
- enable verbose output for the test script
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.17.7 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.17.6 - fix(dns,routes)
|
||||||
|
keep DoH socket-handler routes runtime-only and prune stale persisted entries
|
||||||
|
|
||||||
|
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
|
||||||
|
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
|
||||||
|
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
|
||||||
|
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
|
||||||
|
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
|
||||||
|
normalize target profile route references and stabilize VPN host-IP client routing behavior
|
||||||
|
|
||||||
|
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
|
||||||
|
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
|
||||||
|
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
|
||||||
|
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
|
||||||
|
|
||||||
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
|
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
|
||||||
sync route filter toggle selection via component changeSubject
|
sync route filter toggle selection via component changeSubject
|
||||||
|
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "13.17.3",
|
"version": "13.17.9",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --logfile --timeout 60)",
|
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||||
"start": "(node ./cli.js)",
|
"start": "(node ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
@@ -51,10 +51,10 @@
|
|||||||
"@push.rocks/smartmetrics": "^3.0.3",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
"@push.rocks/smartmigration": "1.2.0",
|
"@push.rocks/smartmigration": "1.2.0",
|
||||||
"@push.rocks/smartmta": "^5.3.1",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartnetwork": "^4.5.2",
|
"@push.rocks/smartnetwork": "^4.6.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^27.6.0",
|
"@push.rocks/smartproxy": "^27.7.4",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
@@ -62,12 +62,12 @@
|
|||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartvpn": "1.19.2",
|
"@push.rocks/smartvpn": "1.19.2",
|
||||||
"@push.rocks/taskbuffer": "^8.0.2",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/catalog": "^2.12.3",
|
"@serve.zone/catalog": "^2.12.4",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^4.15.3",
|
"@serve.zone/remoteingress": "^4.15.3",
|
||||||
"@tsclass/tsclass": "^9.5.0",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"lru-cache": "^11.3.3",
|
"lru-cache": "^11.3.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@@ -72,8 +72,8 @@ importers:
|
|||||||
specifier: ^5.3.1
|
specifier: ^5.3.1
|
||||||
version: 5.3.1
|
version: 5.3.1
|
||||||
'@push.rocks/smartnetwork':
|
'@push.rocks/smartnetwork':
|
||||||
specifier: ^4.5.2
|
specifier: ^4.6.0
|
||||||
version: 4.5.2
|
version: 4.6.0
|
||||||
'@push.rocks/smartpath':
|
'@push.rocks/smartpath':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -81,8 +81,8 @@ importers:
|
|||||||
specifier: ^4.2.3
|
specifier: ^4.2.3
|
||||||
version: 4.2.3
|
version: 4.2.3
|
||||||
'@push.rocks/smartproxy':
|
'@push.rocks/smartproxy':
|
||||||
specifier: ^27.6.0
|
specifier: ^27.7.4
|
||||||
version: 27.6.0
|
version: 27.7.4
|
||||||
'@push.rocks/smartradius':
|
'@push.rocks/smartradius':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
@@ -105,8 +105,8 @@ importers:
|
|||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
'@serve.zone/catalog':
|
'@serve.zone/catalog':
|
||||||
specifier: ^2.12.3
|
specifier: ^2.12.4
|
||||||
version: 2.12.3(@tiptap/pm@2.27.2)
|
version: 2.12.4(@tiptap/pm@2.27.2)
|
||||||
'@serve.zone/interfaces':
|
'@serve.zone/interfaces':
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
@@ -120,8 +120,8 @@ importers:
|
|||||||
specifier: ^1.5.6
|
specifier: ^1.5.6
|
||||||
version: 1.5.6
|
version: 1.5.6
|
||||||
lru-cache:
|
lru-cache:
|
||||||
specifier: ^11.3.3
|
specifier: ^11.3.5
|
||||||
version: 11.3.3
|
version: 11.3.5
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
@@ -365,9 +365,6 @@ packages:
|
|||||||
'@design.estate/dees-element@2.2.4':
|
'@design.estate/dees-element@2.2.4':
|
||||||
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.0':
|
|
||||||
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.9.0':
|
'@design.estate/dees-wcctools@3.9.0':
|
||||||
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
||||||
|
|
||||||
@@ -1260,8 +1257,8 @@ packages:
|
|||||||
'@push.rocks/smartmustache@3.0.2':
|
'@push.rocks/smartmustache@3.0.2':
|
||||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||||
|
|
||||||
'@push.rocks/smartnetwork@4.5.2':
|
'@push.rocks/smartnetwork@4.6.0':
|
||||||
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
|
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
|
||||||
|
|
||||||
'@push.rocks/smartnftables@1.1.0':
|
'@push.rocks/smartnftables@1.1.0':
|
||||||
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||||
@@ -1287,8 +1284,8 @@ packages:
|
|||||||
'@push.rocks/smartpromise@4.2.3':
|
'@push.rocks/smartpromise@4.2.3':
|
||||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||||
|
|
||||||
'@push.rocks/smartproxy@27.6.0':
|
'@push.rocks/smartproxy@27.7.4':
|
||||||
resolution: {integrity: sha512-1mPzabUKhlC0EdeI7Hjee/aiptTsOLftbq8oWBTlIg9JhCQwkIs5UNGTJV/VvlEflJKnay8TbzLzlr95gUr/1w==}
|
resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==}
|
||||||
|
|
||||||
'@push.rocks/smartpuppeteer@2.0.5':
|
'@push.rocks/smartpuppeteer@2.0.5':
|
||||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||||
@@ -1591,8 +1588,8 @@ packages:
|
|||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||||
|
|
||||||
'@serve.zone/catalog@2.12.3':
|
'@serve.zone/catalog@2.12.4':
|
||||||
resolution: {integrity: sha512-/QLFjFcy/ig6cdr4517smSc/VCutW/qF/8lCM3v7tpQ5yLApjqiL314Dyvk9zzSwHpw69IeuM9EmPOeTuCY0iQ==}
|
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
|
||||||
|
|
||||||
'@serve.zone/interfaces@5.3.0':
|
'@serve.zone/interfaces@5.3.0':
|
||||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||||
@@ -3085,8 +3082,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
lru-cache@11.3.3:
|
lru-cache@11.3.5:
|
||||||
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
|
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
lru-cache@7.18.3:
|
lru-cache@7.18.3:
|
||||||
@@ -4937,18 +4934,6 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.0':
|
|
||||||
dependencies:
|
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
|
||||||
'@design.estate/dees-element': 2.2.4
|
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
|
||||||
lit: 3.3.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@nuxt/kit'
|
|
||||||
- react
|
|
||||||
- supports-color
|
|
||||||
- vue
|
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.9.0':
|
'@design.estate/dees-wcctools@3.9.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
@@ -5169,7 +5154,7 @@ snapshots:
|
|||||||
'@push.rocks/smartjson': 6.0.0
|
'@push.rocks/smartjson': 6.0.0
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
|
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@push.rocks/smartrequest': 5.0.1
|
'@push.rocks/smartrequest': 5.0.1
|
||||||
@@ -5972,7 +5957,7 @@ snapshots:
|
|||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartdns': 7.9.0
|
'@push.rocks/smartdns': 7.9.0
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartstring': 4.1.0
|
'@push.rocks/smartstring': 4.1.0
|
||||||
'@push.rocks/smarttime': 4.2.3
|
'@push.rocks/smarttime': 4.2.3
|
||||||
'@push.rocks/smartunique': 3.0.9
|
'@push.rocks/smartunique': 3.0.9
|
||||||
@@ -6429,7 +6414,7 @@ snapshots:
|
|||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
'@tsclass/tsclass': 9.5.0
|
'@tsclass/tsclass': 9.5.0
|
||||||
lru-cache: 11.3.3
|
lru-cache: 11.3.5
|
||||||
mailparser: 3.9.6
|
mailparser: 3.9.6
|
||||||
uuid: 13.0.0
|
uuid: 13.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -6439,7 +6424,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
handlebars: 4.7.9
|
handlebars: 4.7.9
|
||||||
|
|
||||||
'@push.rocks/smartnetwork@4.5.2':
|
'@push.rocks/smartnetwork@4.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartdns': 7.9.0
|
'@push.rocks/smartdns': 7.9.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
@@ -6500,7 +6485,7 @@ snapshots:
|
|||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartfs': 1.5.0
|
'@push.rocks/smartfs': 1.5.0
|
||||||
'@push.rocks/smartjimp': 1.2.0
|
'@push.rocks/smartjimp': 1.2.0
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
|
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
|
||||||
@@ -6521,7 +6506,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/smartpromise@4.2.3': {}
|
'@push.rocks/smartpromise@4.2.3': {}
|
||||||
|
|
||||||
'@push.rocks/smartproxy@27.6.0':
|
'@push.rocks/smartproxy@27.7.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartcrypto': 2.0.4
|
'@push.rocks/smartcrypto': 2.0.4
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
@@ -6923,12 +6908,12 @@ snapshots:
|
|||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
selderee: 0.11.0
|
selderee: 0.11.0
|
||||||
|
|
||||||
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
'@serve.zone/catalog@2.12.4(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
'@design.estate/dees-wcctools': 3.8.0
|
'@design.estate/dees-wcctools': 3.9.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
- '@tiptap/pm'
|
- '@tiptap/pm'
|
||||||
@@ -8659,7 +8644,7 @@ snapshots:
|
|||||||
|
|
||||||
lowercase-keys@3.0.0: {}
|
lowercase-keys@3.0.0: {}
|
||||||
|
|
||||||
lru-cache@11.3.3: {}
|
lru-cache@11.3.5: {}
|
||||||
|
|
||||||
lru-cache@7.18.3: {}
|
lru-cache@7.18.3: {}
|
||||||
|
|
||||||
@@ -9304,7 +9289,7 @@ snapshots:
|
|||||||
|
|
||||||
path-scurry@2.0.2:
|
path-scurry@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 11.3.3
|
lru-cache: 11.3.5
|
||||||
minipass: 7.1.3
|
minipass: 7.1.3
|
||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
|
|||||||
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();
|
||||||
120
test/test.metricsmanager.route-keys.node.ts
Normal file
120
test/test.metricsmanager.route-keys.node.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
|
||||||
|
|
||||||
|
const emptyProtocolDistribution = {
|
||||||
|
h1Active: 0,
|
||||||
|
h1Total: 0,
|
||||||
|
h2Active: 0,
|
||||||
|
h2Total: 0,
|
||||||
|
h3Active: 0,
|
||||||
|
h3Total: 0,
|
||||||
|
wsActive: 0,
|
||||||
|
wsTotal: 0,
|
||||||
|
otherActive: 0,
|
||||||
|
otherTotal: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createProxyMetrics(args: {
|
||||||
|
connectionsByRoute: Map<string, number>;
|
||||||
|
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||||
|
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||||
|
requestsTotal?: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
connections: {
|
||||||
|
active: () => 0,
|
||||||
|
total: () => 0,
|
||||||
|
byRoute: () => args.connectionsByRoute,
|
||||||
|
byIP: () => new Map<string, number>(),
|
||||||
|
topIPs: () => [],
|
||||||
|
domainRequestsByIP: () => args.domainRequestsByIP,
|
||||||
|
topDomainRequests: () => [],
|
||||||
|
frontendProtocols: () => emptyProtocolDistribution,
|
||||||
|
backendProtocols: () => emptyProtocolDistribution,
|
||||||
|
},
|
||||||
|
throughput: {
|
||||||
|
instant: () => ({ in: 0, out: 0 }),
|
||||||
|
recent: () => ({ in: 0, out: 0 }),
|
||||||
|
average: () => ({ in: 0, out: 0 }),
|
||||||
|
custom: () => ({ in: 0, out: 0 }),
|
||||||
|
history: () => [],
|
||||||
|
byRoute: () => args.throughputByRoute,
|
||||||
|
byIP: () => new Map<string, { in: number; out: number }>(),
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
perSecond: () => 0,
|
||||||
|
perMinute: () => 0,
|
||||||
|
total: () => args.requestsTotal || 0,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
bytesIn: () => 0,
|
||||||
|
bytesOut: () => 0,
|
||||||
|
connections: () => 0,
|
||||||
|
},
|
||||||
|
backends: {
|
||||||
|
byBackend: () => new Map<string, any>(),
|
||||||
|
protocols: () => new Map<string, string>(),
|
||||||
|
topByErrors: () => [],
|
||||||
|
detectedProtocols: () => [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
|
||||||
|
const proxyMetrics = createProxyMetrics({
|
||||||
|
connectionsByRoute: new Map([
|
||||||
|
['route-id-only', 4],
|
||||||
|
]),
|
||||||
|
throughputByRoute: new Map([
|
||||||
|
['route-id-only', { in: 1200, out: 2400 }],
|
||||||
|
]),
|
||||||
|
domainRequestsByIP: new Map([
|
||||||
|
['192.0.2.10', new Map([
|
||||||
|
['alpha.example.com', 3],
|
||||||
|
['beta.example.com', 1],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
requestsTotal: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const smartProxy = {
|
||||||
|
getMetrics: () => proxyMetrics,
|
||||||
|
routeManager: {
|
||||||
|
getRoutes: () => [
|
||||||
|
{
|
||||||
|
id: 'route-id-only',
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
domains: ['alpha.example.com', 'beta.example.com'],
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new MetricsManager({ smartProxy } as any);
|
||||||
|
const stats = await manager.getNetworkStats();
|
||||||
|
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
|
||||||
|
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
|
||||||
|
|
||||||
|
expect(alpha).toBeDefined();
|
||||||
|
expect(beta).toBeDefined();
|
||||||
|
|
||||||
|
expect(alpha!.requestCount).toEqual(3);
|
||||||
|
expect(alpha!.routeCount).toEqual(1);
|
||||||
|
expect(alpha!.activeConnections).toEqual(3);
|
||||||
|
expect(alpha!.bytesInPerSecond).toEqual(900);
|
||||||
|
expect(alpha!.bytesOutPerSecond).toEqual(1800);
|
||||||
|
|
||||||
|
expect(beta!.requestCount).toEqual(1);
|
||||||
|
expect(beta!.routeCount).toEqual(1);
|
||||||
|
expect(beta!.activeConnections).toEqual(1);
|
||||||
|
expect(beta!.bytesInPerSecond).toEqual(300);
|
||||||
|
expect(beta!.bytesOutPerSecond).toEqual(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.17.3',
|
version: '13.17.9',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ export class DcRouter {
|
|||||||
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
||||||
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
// Runtime-only DoH routes. These carry live socket handlers and must never be persisted.
|
||||||
|
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
// Environment access
|
// Environment access
|
||||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||||
@@ -547,7 +548,9 @@ export class DcRouter {
|
|||||||
await this.referenceResolver.initialize();
|
await this.referenceResolver.initialize();
|
||||||
|
|
||||||
// Initialize target profile manager
|
// Initialize target profile manager
|
||||||
this.targetProfileManager = new TargetProfileManager();
|
this.targetProfileManager = new TargetProfileManager(
|
||||||
|
() => this.routeConfigManager?.getRoutes() || new Map(),
|
||||||
|
);
|
||||||
await this.targetProfileManager.initialize();
|
await this.targetProfileManager.initialize();
|
||||||
|
|
||||||
this.routeConfigManager = new RouteConfigManager(
|
this.routeConfigManager = new RouteConfigManager(
|
||||||
@@ -560,7 +563,10 @@ export class DcRouter {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.targetProfileManager.getMatchingClientIps(
|
return this.targetProfileManager.getMatchingClientIps(
|
||||||
route, routeId, this.vpnManager.listClients(),
|
route,
|
||||||
|
routeId,
|
||||||
|
this.vpnManager.listClients(),
|
||||||
|
this.routeConfigManager?.getRoutes() || new Map(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -575,14 +581,15 @@ export class DcRouter {
|
|||||||
this.tunnelManager.syncAllowedEdges();
|
this.tunnelManager.syncAllowedEdges();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
() => this.runtimeDnsRoutes,
|
||||||
);
|
);
|
||||||
this.apiTokenManager = new ApiTokenManager();
|
this.apiTokenManager = new ApiTokenManager();
|
||||||
await this.apiTokenManager.initialize();
|
await this.apiTokenManager.initialize();
|
||||||
await this.routeConfigManager.initialize(
|
await this.routeConfigManager.initialize(
|
||||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
|
||||||
);
|
);
|
||||||
|
await this.targetProfileManager.normalizeAllRouteRefs();
|
||||||
|
|
||||||
// Seed default profiles/targets if DB is empty and seeding is enabled
|
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||||
const seeder = new DbSeeder(this.referenceResolver);
|
const seeder = new DbSeeder(this.referenceResolver);
|
||||||
@@ -886,7 +893,7 @@ export class DcRouter {
|
|||||||
this.smartProxy = undefined;
|
this.smartProxy = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble seed routes from constructor config — these will be seeded into DB
|
// Assemble serializable seed routes from constructor config — these will be seeded into DB
|
||||||
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
|
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
|
||||||
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
|
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
|
||||||
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
|
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
|
||||||
@@ -897,17 +904,17 @@ export class DcRouter {
|
|||||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.seedDnsRoutes = [];
|
this.runtimeDnsRoutes = [];
|
||||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||||
this.seedDnsRoutes = this.generateDnsRoutes();
|
this.runtimeDnsRoutes = this.generateDnsRoutes();
|
||||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
|
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)
|
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
||||||
let routes: plugins.smartproxy.IRouteConfig[] = [
|
let routes: plugins.smartproxy.IRouteConfig[] = [
|
||||||
...this.seedConfigRoutes,
|
...this.seedConfigRoutes,
|
||||||
...this.seedEmailRoutes,
|
...this.seedEmailRoutes,
|
||||||
...this.seedDnsRoutes,
|
...this.runtimeDnsRoutes,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
||||||
@@ -1457,7 +1464,6 @@ export class DcRouter {
|
|||||||
await this.routeConfigManager.initialize(
|
await this.routeConfigManager.initialize(
|
||||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2179,7 +2185,7 @@ export class DcRouter {
|
|||||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||||
// will push the complete merged routes here.
|
// will push the complete merged routes here.
|
||||||
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.seedDnsRoutes];
|
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
|
||||||
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
||||||
|
|
||||||
// If ConfigManagers finished before us, re-apply routes
|
// If ConfigManagers finished before us, re-apply routes
|
||||||
@@ -2283,8 +2289,11 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Resolve DNS A records for matched domains (with caching)
|
// Resolve DNS A records for matched domains (with caching)
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const stripped = domain.replace(/^\*\./, '');
|
if (this.isWildcardVpnDomain(domain)) {
|
||||||
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
this.logSkippedWildcardAllowedIp(domain);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||||
for (const ip of resolvedIps) {
|
for (const ip of resolvedIps) {
|
||||||
ips.add(`${ip}/32`);
|
ips.add(`${ip}/32`);
|
||||||
}
|
}
|
||||||
@@ -2303,6 +2312,8 @@ export class DcRouter {
|
|||||||
|
|
||||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||||
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
||||||
|
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
|
||||||
|
private warnedWildcardVpnDomains = new Set<string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
||||||
@@ -2328,6 +2339,19 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isWildcardVpnDomain(domain: string): boolean {
|
||||||
|
return domain.includes('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
private logSkippedWildcardAllowedIp(domain: string): void {
|
||||||
|
if (this.warnedWildcardVpnDomains.has(domain)) return;
|
||||||
|
this.warnedWildcardVpnDomains.add(domain);
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
||||||
// via the getVpnAllowList callback — no longer a separate method here.
|
// via the getVpnAllowList callback — no longer a separate method here.
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export class RouteConfigManager {
|
|||||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||||
private referenceResolver?: ReferenceResolver,
|
private referenceResolver?: ReferenceResolver,
|
||||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||||
|
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Expose routes map for reference resolution lookups. */
|
/** Expose routes map for reference resolution lookups. */
|
||||||
@@ -63,7 +64,8 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load persisted routes, seed config/email/dns routes, compute warnings, apply to SmartProxy.
|
* Load persisted routes, seed serializable config/email/dns routes,
|
||||||
|
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||||
*/
|
*/
|
||||||
public async initialize(
|
public async initialize(
|
||||||
configRoutes: IDcRouterRouteConfig[] = [],
|
configRoutes: IDcRouterRouteConfig[] = [],
|
||||||
@@ -284,9 +286,12 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
private async loadRoutes(): Promise<void> {
|
private async loadRoutes(): Promise<void> {
|
||||||
const docs = await RouteDoc.findAll();
|
const docs = await RouteDoc.findAll();
|
||||||
|
let prunedRuntimeRoutes = 0;
|
||||||
|
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
if (doc.id) {
|
if (!doc.id) continue;
|
||||||
this.routes.set(doc.id, {
|
|
||||||
|
const storedRoute: IRoute = {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
route: doc.route,
|
route: doc.route,
|
||||||
enabled: doc.enabled,
|
enabled: doc.enabled,
|
||||||
@@ -295,12 +300,26 @@ export class RouteConfigManager {
|
|||||||
createdBy: doc.createdBy,
|
createdBy: doc.createdBy,
|
||||||
origin: doc.origin || 'api',
|
origin: doc.origin || 'api',
|
||||||
metadata: doc.metadata,
|
metadata: doc.metadata,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (this.isPersistedRuntimeRoute(storedRoute)) {
|
||||||
|
await doc.delete();
|
||||||
|
prunedRuntimeRoutes++;
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.routes.set(doc.id, storedRoute);
|
||||||
}
|
}
|
||||||
if (this.routes.size > 0) {
|
if (this.routes.size > 0) {
|
||||||
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
||||||
}
|
}
|
||||||
|
if (prunedRuntimeRoutes > 0) {
|
||||||
|
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistRoute(stored: IRoute): Promise<void> {
|
private async persistRoute(stored: IRoute): Promise<void> {
|
||||||
@@ -389,34 +408,16 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
const http3Config = this.getHttp3Config?.();
|
|
||||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
|
||||||
|
|
||||||
// Helper: inject VPN security into a vpnOnly route
|
|
||||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
|
||||||
if (!vpnCallback) return route;
|
|
||||||
const dcRoute = route as IDcRouterRouteConfig;
|
|
||||||
if (!dcRoute.vpnOnly) return route;
|
|
||||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
|
||||||
const existingEntries = route.security?.ipAllowList || [];
|
|
||||||
return {
|
|
||||||
...route,
|
|
||||||
security: {
|
|
||||||
...route.security,
|
|
||||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||||
for (const route of this.routes.values()) {
|
for (const route of this.routes.values()) {
|
||||||
if (route.enabled) {
|
if (route.enabled) {
|
||||||
let r = route.route;
|
enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
|
||||||
if (http3Config?.enabled !== false) {
|
|
||||||
r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config });
|
|
||||||
}
|
}
|
||||||
enabledRoutes.push(injectVpn(r, route.id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||||
|
for (const route of runtimeRoutes) {
|
||||||
|
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||||
}
|
}
|
||||||
|
|
||||||
await smartProxy.updateRoutes(enabledRoutes);
|
await smartProxy.updateRoutes(enabledRoutes);
|
||||||
@@ -429,4 +430,47 @@ export class RouteConfigManager {
|
|||||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
|||||||
export class TargetProfileManager {
|
export class TargetProfileManager {
|
||||||
private profiles = new Map<string, ITargetProfile>();
|
private profiles = new Map<string, ITargetProfile>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private getAllRoutes?: () => Map<string, IRoute>,
|
||||||
|
) {}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -43,13 +47,14 @@ export class TargetProfileManager {
|
|||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||||
const profile: ITargetProfile = {
|
const profile: ITargetProfile = {
|
||||||
id,
|
id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
domains: data.domains,
|
domains: data.domains,
|
||||||
targets: data.targets,
|
targets: data.targets,
|
||||||
routeRefs: data.routeRefs,
|
routeRefs,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
@@ -70,11 +75,19 @@ export class TargetProfileManager {
|
|||||||
throw new Error(`Target profile '${id}' not found`);
|
throw new Error(`Target profile '${id}' not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined && patch.name !== profile.name) {
|
||||||
|
for (const existing of this.profiles.values()) {
|
||||||
|
if (existing.id !== id && existing.name === patch.name) {
|
||||||
|
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (patch.name !== undefined) profile.name = patch.name;
|
if (patch.name !== undefined) profile.name = patch.name;
|
||||||
if (patch.description !== undefined) profile.description = patch.description;
|
if (patch.description !== undefined) profile.description = patch.description;
|
||||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||||
if (patch.targets !== undefined) profile.targets = patch.targets;
|
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||||
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
|
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
||||||
profile.updatedAt = Date.now();
|
profile.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.persistProfile(profile);
|
await this.persistProfile(profile);
|
||||||
@@ -127,6 +140,29 @@ export class TargetProfileManager {
|
|||||||
return this.profiles.get(id);
|
return this.profiles.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize stored route references to route IDs when they can be resolved
|
||||||
|
* uniquely against the current route registry.
|
||||||
|
*/
|
||||||
|
public async normalizeAllRouteRefs(): Promise<void> {
|
||||||
|
const allRoutes = this.getAllRoutes?.();
|
||||||
|
if (!allRoutes?.size) return;
|
||||||
|
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
||||||
|
profile.routeRefs,
|
||||||
|
allRoutes,
|
||||||
|
'bestEffort',
|
||||||
|
);
|
||||||
|
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
||||||
|
|
||||||
|
profile.routeRefs = normalizedRouteRefs;
|
||||||
|
profile.updatedAt = Date.now();
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public listProfiles(): ITargetProfile[] {
|
public listProfiles(): ITargetProfile[] {
|
||||||
return [...this.profiles.values()];
|
return [...this.profiles.values()];
|
||||||
}
|
}
|
||||||
@@ -178,9 +214,11 @@ export class TargetProfileManager {
|
|||||||
route: IDcRouterRouteConfig,
|
route: IDcRouterRouteConfig,
|
||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
clients: VpnClientDoc[],
|
clients: VpnClientDoc[],
|
||||||
|
allRoutes: Map<string, IRoute> = new Map(),
|
||||||
): Array<string | { ip: string; domains: string[] }> {
|
): Array<string | { ip: string; domains: string[] }> {
|
||||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (!client.enabled || !client.assignedIp) continue;
|
if (!client.enabled || !client.assignedIp) continue;
|
||||||
@@ -194,7 +232,13 @@ export class TargetProfileManager {
|
|||||||
const profile = this.profiles.get(profileId);
|
const profile = this.profiles.get(profileId);
|
||||||
if (!profile) continue;
|
if (!profile) continue;
|
||||||
|
|
||||||
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
const matchResult = this.routeMatchesProfileDetailed(
|
||||||
|
route,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeDomains,
|
||||||
|
routeNameIndex,
|
||||||
|
);
|
||||||
if (matchResult === 'full') {
|
if (matchResult === 'full') {
|
||||||
fullAccess = true;
|
fullAccess = true;
|
||||||
break; // No need to check more profiles
|
break; // No need to check more profiles
|
||||||
@@ -224,6 +268,7 @@ export class TargetProfileManager {
|
|||||||
): { domains: string[]; targetIps: string[] } {
|
): { domains: string[]; targetIps: string[] } {
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
const targetIps = new Set<string>();
|
const targetIps = new Set<string>();
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
// Collect all access specifiers from assigned profiles
|
// Collect all access specifiers from assigned profiles
|
||||||
for (const profileId of targetProfileIds) {
|
for (const profileId of targetProfileIds) {
|
||||||
@@ -247,7 +292,12 @@ export class TargetProfileManager {
|
|||||||
// Route references: scan all routes
|
// Route references: scan all routes
|
||||||
for (const [routeId, route] of allRoutes) {
|
for (const [routeId, route] of allRoutes) {
|
||||||
if (!route.enabled) continue;
|
if (!route.enabled) continue;
|
||||||
if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
|
if (this.routeMatchesProfile(
|
||||||
|
route.route as IDcRouterRouteConfig,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeNameIndex,
|
||||||
|
)) {
|
||||||
const routeDomains = (route.route.match as any)?.domains;
|
const routeDomains = (route.route.match as any)?.domains;
|
||||||
if (Array.isArray(routeDomains)) {
|
if (Array.isArray(routeDomains)) {
|
||||||
for (const d of routeDomains) {
|
for (const d of routeDomains) {
|
||||||
@@ -275,9 +325,16 @@ export class TargetProfileManager {
|
|||||||
route: IDcRouterRouteConfig,
|
route: IDcRouterRouteConfig,
|
||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
profile: ITargetProfile,
|
profile: ITargetProfile,
|
||||||
|
routeNameIndex: Map<string, string[]>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||||
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
const result = this.routeMatchesProfileDetailed(
|
||||||
|
route,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeDomains,
|
||||||
|
routeNameIndex,
|
||||||
|
);
|
||||||
return result !== 'none';
|
return result !== 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,11 +351,17 @@ export class TargetProfileManager {
|
|||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
profile: ITargetProfile,
|
profile: ITargetProfile,
|
||||||
routeDomains: string[],
|
routeDomains: string[],
|
||||||
|
routeNameIndex: Map<string, string[]>,
|
||||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||||
// 1. Route reference match → full access
|
// 1. Route reference match → full access
|
||||||
if (profile.routeRefs?.length) {
|
if (profile.routeRefs?.length) {
|
||||||
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
||||||
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
|
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
||||||
|
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
||||||
|
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Domain match
|
// 2. Domain match
|
||||||
@@ -362,6 +425,66 @@ export class TargetProfileManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||||
|
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||||
|
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteRefsAgainstRoutes(
|
||||||
|
routeRefs: string[] | undefined,
|
||||||
|
allRoutes: Map<string, IRoute>,
|
||||||
|
mode: 'strict' | 'bestEffort',
|
||||||
|
): string[] | undefined {
|
||||||
|
if (!routeRefs?.length) return undefined;
|
||||||
|
if (!allRoutes.size) return [...new Set(routeRefs)];
|
||||||
|
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
const normalizedRefs = new Set<string>();
|
||||||
|
|
||||||
|
for (const routeRef of routeRefs) {
|
||||||
|
if (allRoutes.has(routeRef)) {
|
||||||
|
normalizedRefs.add(routeRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
||||||
|
if (matchingRouteIds.length === 1) {
|
||||||
|
normalizedRefs.add(matchingRouteIds[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'bestEffort') {
|
||||||
|
normalizedRefs.add(routeRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingRouteIds.length > 1) {
|
||||||
|
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
||||||
|
}
|
||||||
|
throw new Error(`Route reference '${routeRef}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...normalizedRefs];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
||||||
|
const routeNameIndex = new Map<string, string[]>();
|
||||||
|
for (const [routeId, route] of allRoutes) {
|
||||||
|
const routeName = route.route.name;
|
||||||
|
if (!routeName) continue;
|
||||||
|
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
||||||
|
matchingRouteIds.push(routeId);
|
||||||
|
routeNameIndex.set(routeName, matchingRouteIds);
|
||||||
|
}
|
||||||
|
return routeNameIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameStringArray(left?: string[], right?: string[]): boolean {
|
||||||
|
if (!left?.length && !right?.length) return true;
|
||||||
|
if (!left || !right || left.length !== right.length) return false;
|
||||||
|
return left.every((value, index) => value === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: persistence
|
// Private: persistence
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ export class DnsManager {
|
|||||||
if (hasLegacyConfig) {
|
if (hasLegacyConfig) {
|
||||||
logger.log(
|
logger.log(
|
||||||
'warn',
|
'warn',
|
||||||
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
|
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
|
||||||
'Manage DNS via the Domains UI instead.',
|
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -733,16 +733,17 @@ export class MetricsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map route name → domains from route config
|
// Map canonical route key → domains from route config
|
||||||
const routeDomains = new Map<string, string[]>();
|
const routeDomains = new Map<string, string[]>();
|
||||||
if (this.dcRouter.smartProxy) {
|
if (this.dcRouter.smartProxy) {
|
||||||
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
||||||
if (!route.name || !route.match.domains) continue;
|
const routeKey = route.name || route.id;
|
||||||
|
if (!routeKey || !route.match.domains) continue;
|
||||||
const domains = Array.isArray(route.match.domains)
|
const domains = Array.isArray(route.match.domains)
|
||||||
? route.match.domains
|
? route.match.domains
|
||||||
: [route.match.domains];
|
: [route.match.domains];
|
||||||
if (domains.length > 0) {
|
if (domains.length > 0) {
|
||||||
routeDomains.set(route.name, domains);
|
routeDomains.set(routeKey, domains);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -753,23 +754,23 @@ export class MetricsManager {
|
|||||||
if (entry.domain) allKnownDomains.add(entry.domain);
|
if (entry.domain) allKnownDomains.add(entry.domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reverse map: concrete domain → route name(s)
|
// Build reverse map: concrete domain → canonical route key(s)
|
||||||
const domainToRoutes = new Map<string, string[]>();
|
const domainToRoutes = new Map<string, string[]>();
|
||||||
for (const [routeName, domains] of routeDomains) {
|
for (const [routeKey, domains] of routeDomains) {
|
||||||
for (const pattern of domains) {
|
for (const pattern of domains) {
|
||||||
if (pattern.includes('*')) {
|
if (pattern.includes('*')) {
|
||||||
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
||||||
for (const knownDomain of allKnownDomains) {
|
for (const knownDomain of allKnownDomains) {
|
||||||
if (regex.test(knownDomain)) {
|
if (regex.test(knownDomain)) {
|
||||||
const existing = domainToRoutes.get(knownDomain);
|
const existing = domainToRoutes.get(knownDomain);
|
||||||
if (existing) { existing.push(routeName); }
|
if (existing) { existing.push(routeKey); }
|
||||||
else { domainToRoutes.set(knownDomain, [routeName]); }
|
else { domainToRoutes.set(knownDomain, [routeKey]); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const existing = domainToRoutes.get(pattern);
|
const existing = domainToRoutes.get(pattern);
|
||||||
if (existing) { existing.push(routeName); }
|
if (existing) { existing.push(routeKey); }
|
||||||
else { domainToRoutes.set(pattern, [routeName]); }
|
else { domainToRoutes.set(pattern, [routeKey]); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -777,10 +778,10 @@ export class MetricsManager {
|
|||||||
// For each route, compute the total request count across all its resolved domains
|
// For each route, compute the total request count across all its resolved domains
|
||||||
// so we can distribute throughput/connections proportionally
|
// so we can distribute throughput/connections proportionally
|
||||||
const routeTotalRequests = new Map<string, number>();
|
const routeTotalRequests = new Map<string, number>();
|
||||||
for (const [domain, routeNames] of domainToRoutes) {
|
for (const [domain, routeKeys] of domainToRoutes) {
|
||||||
const reqs = domainRequestTotals.get(domain) || 0;
|
const reqs = domainRequestTotals.get(domain) || 0;
|
||||||
for (const routeName of routeNames) {
|
for (const routeKey of routeKeys) {
|
||||||
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 0) + reqs);
|
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,16 +794,16 @@ export class MetricsManager {
|
|||||||
requestCount: number;
|
requestCount: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
for (const [domain, routeNames] of domainToRoutes) {
|
for (const [domain, routeKeys] of domainToRoutes) {
|
||||||
const domainReqs = domainRequestTotals.get(domain) || 0;
|
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||||
let totalConns = 0;
|
let totalConns = 0;
|
||||||
let totalIn = 0;
|
let totalIn = 0;
|
||||||
let totalOut = 0;
|
let totalOut = 0;
|
||||||
|
|
||||||
for (const routeName of routeNames) {
|
for (const routeKey of routeKeys) {
|
||||||
const conns = connectionsByRoute.get(routeName) || 0;
|
const conns = connectionsByRoute.get(routeKey) || 0;
|
||||||
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
|
||||||
const routeTotal = routeTotalRequests.get(routeName) || 0;
|
const routeTotal = routeTotalRequests.get(routeKey) || 0;
|
||||||
|
|
||||||
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
||||||
totalConns += conns * share;
|
totalConns += conns * share;
|
||||||
@@ -814,7 +815,7 @@ export class MetricsManager {
|
|||||||
activeConnections: Math.round(totalConns),
|
activeConnections: Math.round(totalConns),
|
||||||
bytesInPerSec: totalIn,
|
bytesInPerSec: totalIn,
|
||||||
bytesOutPerSec: totalOut,
|
bytesOutPerSec: totalOut,
|
||||||
routeCount: routeNames.length,
|
routeCount: routeKeys.length,
|
||||||
requestCount: domainReqs,
|
requestCount: domainReqs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,12 +198,11 @@ export class CertificateHandler {
|
|||||||
try {
|
try {
|
||||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||||
if (rustStatus) {
|
if (rustStatus) {
|
||||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
if (rustStatus.expiresAt > 0) {
|
||||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
expiryDate = new Date(rustStatus.expiresAt).toISOString();
|
||||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
|
||||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
|
||||||
status = rustStatus.status;
|
|
||||||
}
|
}
|
||||||
|
if (rustStatus.source) issuer = rustStatus.source;
|
||||||
|
status = rustStatus.isValid ? 'valid' : 'expired';
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Rust bridge may not support this command yet — ignore
|
// Rust bridge may not support this command yet — ignore
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export class VpnManager {
|
|||||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||||
private clients: Map<string, VpnClientDoc> = new Map();
|
private clients: Map<string, VpnClientDoc> = new Map();
|
||||||
private serverKeys?: VpnServerKeysDoc;
|
private serverKeys?: VpnServerKeysDoc;
|
||||||
|
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
|
||||||
constructor(config: IVpnManagerConfig) {
|
constructor(config: IVpnManagerConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -88,6 +90,7 @@ export class VpnManager {
|
|||||||
if (client.useHostIp) {
|
if (client.useHostIp) {
|
||||||
anyClientUsesHostIp = true;
|
anyClientUsesHostIp = true;
|
||||||
}
|
}
|
||||||
|
this.normalizeClientRoutingSettings(client);
|
||||||
const entry: plugins.smartvpn.IClientEntry = {
|
const entry: plugins.smartvpn.IClientEntry = {
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
publicKey: client.noisePublicKey,
|
publicKey: client.noisePublicKey,
|
||||||
@@ -97,13 +100,12 @@ export class VpnManager {
|
|||||||
assignedIp: client.assignedIp,
|
assignedIp: client.assignedIp,
|
||||||
expiresAt: client.expiresAt,
|
expiresAt: client.expiresAt,
|
||||||
security: this.buildClientSecurity(client),
|
security: this.buildClientSecurity(client),
|
||||||
|
useHostIp: client.useHostIp,
|
||||||
|
useDhcp: client.useDhcp,
|
||||||
|
staticIp: client.staticIp,
|
||||||
|
forceVlan: client.forceVlan,
|
||||||
|
vlanId: client.vlanId,
|
||||||
};
|
};
|
||||||
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
|
||||||
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
|
||||||
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
|
||||||
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
|
||||||
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
|
||||||
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
|
||||||
clientEntries.push(entry);
|
clientEntries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,13 +114,15 @@ export class VpnManager {
|
|||||||
|
|
||||||
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||||
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||||
let configuredMode = this.config.forwardingMode ?? 'socket';
|
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||||
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||||
configuredMode = 'hybrid';
|
configuredMode = 'hybrid';
|
||||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||||
}
|
}
|
||||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||||
const isBridge = forwardingMode === 'bridge';
|
const isBridge = forwardingMode === 'bridge';
|
||||||
|
this.resolvedForwardingMode = forwardingMode;
|
||||||
|
this.forwardingModeOverride = undefined;
|
||||||
|
|
||||||
// Create and start VpnServer
|
// Create and start VpnServer
|
||||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||||
@@ -143,7 +147,7 @@ export class VpnManager {
|
|||||||
wgListenPort,
|
wgListenPort,
|
||||||
clients: clientEntries,
|
clients: clientEntries,
|
||||||
socketForwardProxyProtocol: !isBridge,
|
socketForwardProxyProtocol: !isBridge,
|
||||||
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||||
serverEndpoint: this.config.serverEndpoint
|
serverEndpoint: this.config.serverEndpoint
|
||||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -189,6 +193,7 @@ export class VpnManager {
|
|||||||
this.vpnServer.stop();
|
this.vpnServer.stop();
|
||||||
this.vpnServer = undefined;
|
this.vpnServer = undefined;
|
||||||
}
|
}
|
||||||
|
this.resolvedForwardingMode = undefined;
|
||||||
logger.log('info', 'VPN server stopped');
|
logger.log('info', 'VPN server stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,14 +218,38 @@ export class VpnManager {
|
|||||||
throw new Error('VPN server not running');
|
throw new Error('VPN server not running');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
|
||||||
|
|
||||||
|
const doc = new VpnClientDoc();
|
||||||
|
doc.clientId = opts.clientId;
|
||||||
|
doc.enabled = true;
|
||||||
|
doc.targetProfileIds = opts.targetProfileIds;
|
||||||
|
doc.description = opts.description;
|
||||||
|
doc.destinationAllowList = opts.destinationAllowList;
|
||||||
|
doc.destinationBlockList = opts.destinationBlockList;
|
||||||
|
doc.useHostIp = opts.useHostIp;
|
||||||
|
doc.useDhcp = opts.useDhcp;
|
||||||
|
doc.staticIp = opts.staticIp;
|
||||||
|
doc.forceVlan = opts.forceVlan;
|
||||||
|
doc.vlanId = opts.vlanId;
|
||||||
|
doc.createdAt = Date.now();
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
this.normalizeClientRoutingSettings(doc);
|
||||||
|
|
||||||
const bundle = await this.vpnServer.createClient({
|
const bundle = await this.vpnServer.createClient({
|
||||||
clientId: opts.clientId,
|
clientId: doc.clientId,
|
||||||
description: opts.description,
|
description: doc.description,
|
||||||
|
security: this.buildClientSecurity(doc),
|
||||||
|
useHostIp: doc.useHostIp,
|
||||||
|
useDhcp: doc.useDhcp,
|
||||||
|
staticIp: doc.staticIp,
|
||||||
|
forceVlan: doc.forceVlan,
|
||||||
|
vlanId: doc.vlanId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Override AllowedIPs with per-client values based on target profiles
|
// Override AllowedIPs with per-client values based on target profiles
|
||||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
|
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
||||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
/AllowedIPs\s*=\s*.+/,
|
/AllowedIPs\s*=\s*.+/,
|
||||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
@@ -228,40 +257,16 @@ export class VpnManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist client entry (including WG private key for export/QR)
|
// Persist client entry (including WG private key for export/QR)
|
||||||
const doc = new VpnClientDoc();
|
|
||||||
doc.clientId = bundle.entry.clientId;
|
doc.clientId = bundle.entry.clientId;
|
||||||
doc.enabled = bundle.entry.enabled ?? true;
|
doc.enabled = bundle.entry.enabled ?? true;
|
||||||
doc.targetProfileIds = opts.targetProfileIds;
|
|
||||||
doc.description = bundle.entry.description;
|
doc.description = bundle.entry.description;
|
||||||
doc.assignedIp = bundle.entry.assignedIp;
|
doc.assignedIp = bundle.entry.assignedIp;
|
||||||
doc.noisePublicKey = bundle.entry.publicKey;
|
doc.noisePublicKey = bundle.entry.publicKey;
|
||||||
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||||
doc.createdAt = Date.now();
|
|
||||||
doc.updatedAt = Date.now();
|
doc.updatedAt = Date.now();
|
||||||
doc.expiresAt = bundle.entry.expiresAt;
|
doc.expiresAt = bundle.entry.expiresAt;
|
||||||
if (opts.destinationAllowList !== undefined) {
|
|
||||||
doc.destinationAllowList = opts.destinationAllowList;
|
|
||||||
}
|
|
||||||
if (opts.destinationBlockList !== undefined) {
|
|
||||||
doc.destinationBlockList = opts.destinationBlockList;
|
|
||||||
}
|
|
||||||
if (opts.useHostIp !== undefined) {
|
|
||||||
doc.useHostIp = opts.useHostIp;
|
|
||||||
}
|
|
||||||
if (opts.useDhcp !== undefined) {
|
|
||||||
doc.useDhcp = opts.useDhcp;
|
|
||||||
}
|
|
||||||
if (opts.staticIp !== undefined) {
|
|
||||||
doc.staticIp = opts.staticIp;
|
|
||||||
}
|
|
||||||
if (opts.forceVlan !== undefined) {
|
|
||||||
doc.forceVlan = opts.forceVlan;
|
|
||||||
}
|
|
||||||
if (opts.vlanId !== undefined) {
|
|
||||||
doc.vlanId = opts.vlanId;
|
|
||||||
}
|
|
||||||
this.clients.set(doc.clientId, doc);
|
this.clients.set(doc.clientId, doc);
|
||||||
try {
|
try {
|
||||||
await this.persistClient(doc);
|
await this.persistClient(doc);
|
||||||
@@ -276,12 +281,6 @@ export class VpnManager {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync per-client security to the running daemon
|
|
||||||
const security = this.buildClientSecurity(doc);
|
|
||||||
if (security.destinationPolicy) {
|
|
||||||
await this.vpnServer!.updateClient(doc.clientId, { security });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.config.onClientChanged?.();
|
this.config.onClientChanged?.();
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
@@ -364,13 +363,13 @@ export class VpnManager {
|
|||||||
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||||
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||||
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||||
|
this.normalizeClientRoutingSettings(client);
|
||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
|
|
||||||
// Sync per-client security to the running daemon
|
|
||||||
if (this.vpnServer) {
|
if (this.vpnServer) {
|
||||||
const security = this.buildClientSecurity(client);
|
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||||
await this.vpnServer.updateClient(clientId, { security });
|
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.config.onClientChanged?.();
|
this.config.onClientChanged?.();
|
||||||
@@ -478,26 +477,28 @@ export class VpnManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build per-client security settings for the smartvpn daemon.
|
* Build per-client security settings for the smartvpn daemon.
|
||||||
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
|
* TargetProfile direct IP:port targets extend the effective allow-list.
|
||||||
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
|
||||||
*/
|
*/
|
||||||
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||||
const security: plugins.smartvpn.IClientSecurity = {};
|
const security: plugins.smartvpn.IClientSecurity = {};
|
||||||
|
const basePolicy = this.getBaseDestinationPolicy(client);
|
||||||
|
|
||||||
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
|
|
||||||
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||||
|
const mergedAllowList = this.mergeDestinationLists(
|
||||||
// Merge with per-client explicit allow list
|
basePolicy.allowList,
|
||||||
const mergedAllowList = [
|
client.destinationAllowList,
|
||||||
...(client.destinationAllowList || []),
|
profileDirectTargets,
|
||||||
...profileDirectTargets,
|
);
|
||||||
];
|
const mergedBlockList = this.mergeDestinationLists(
|
||||||
|
basePolicy.blockList,
|
||||||
|
client.destinationBlockList,
|
||||||
|
);
|
||||||
|
|
||||||
security.destinationPolicy = {
|
security.destinationPolicy = {
|
||||||
default: 'forceTarget' as const,
|
default: basePolicy.default,
|
||||||
target: '127.0.0.1',
|
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
||||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||||
blockList: client.destinationBlockList,
|
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return security;
|
return security;
|
||||||
@@ -510,10 +511,7 @@ export class VpnManager {
|
|||||||
public async refreshAllClientSecurity(): Promise<void> {
|
public async refreshAllClientSecurity(): Promise<void> {
|
||||||
if (!this.vpnServer) return;
|
if (!this.vpnServer) return;
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
const security = this.buildClientSecurity(client);
|
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
||||||
if (security.destinationPolicy) {
|
|
||||||
await this.vpnServer.updateClient(client.clientId, { security });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +548,7 @@ export class VpnManager {
|
|||||||
private async loadPersistedClients(): Promise<void> {
|
private async loadPersistedClients(): Promise<void> {
|
||||||
const docs = await VpnClientDoc.findAll();
|
const docs = await VpnClientDoc.findAll();
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
|
this.normalizeClientRoutingSettings(doc);
|
||||||
this.clients.set(doc.clientId, doc);
|
this.clients.set(doc.clientId, doc);
|
||||||
}
|
}
|
||||||
if (this.clients.size > 0) {
|
if (this.clients.size > 0) {
|
||||||
@@ -557,6 +556,93 @@ export class VpnManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
||||||
|
return this.resolvedForwardingMode
|
||||||
|
?? this.forwardingModeOverride
|
||||||
|
?? this.config.forwardingMode
|
||||||
|
?? 'socket';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultDestinationPolicy(
|
||||||
|
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||||
|
useHostIp = false,
|
||||||
|
): plugins.smartvpn.IDestinationPolicy {
|
||||||
|
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
|
||||||
|
return { default: 'allow' };
|
||||||
|
}
|
||||||
|
return { default: 'forceTarget', target: '127.0.0.1' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServerDestinationPolicy(
|
||||||
|
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||||
|
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
|
||||||
|
): plugins.smartvpn.IDestinationPolicy {
|
||||||
|
return this.config.destinationPolicy ?? fallbackPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
|
||||||
|
if (this.config.destinationPolicy) {
|
||||||
|
return { ...this.config.destinationPolicy };
|
||||||
|
}
|
||||||
|
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
|
||||||
|
const merged = new Set<string>();
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const entry of list || []) {
|
||||||
|
merged.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...merged];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeClientRoutingSettings(
|
||||||
|
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
|
||||||
|
): void {
|
||||||
|
client.useHostIp = client.useHostIp === true;
|
||||||
|
|
||||||
|
if (!client.useHostIp) {
|
||||||
|
client.useDhcp = false;
|
||||||
|
client.staticIp = undefined;
|
||||||
|
client.forceVlan = false;
|
||||||
|
client.vlanId = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.useDhcp = client.useDhcp === true;
|
||||||
|
if (client.useDhcp) {
|
||||||
|
client.staticIp = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.forceVlan = client.forceVlan === true;
|
||||||
|
if (!client.forceVlan) {
|
||||||
|
client.vlanId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
|
||||||
|
return {
|
||||||
|
description: client.description,
|
||||||
|
security: this.buildClientSecurity(client),
|
||||||
|
useHostIp: client.useHostIp,
|
||||||
|
useDhcp: client.useDhcp,
|
||||||
|
staticIp: client.staticIp,
|
||||||
|
forceVlan: client.forceVlan,
|
||||||
|
vlanId: client.vlanId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
|
||||||
|
if (!useHostIp || !this.vpnServer) return;
|
||||||
|
if (this.getResolvedForwardingMode() !== 'socket') return;
|
||||||
|
|
||||||
|
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
|
||||||
|
this.forwardingModeOverride = 'hybrid';
|
||||||
|
await this.stop();
|
||||||
|
await this.start();
|
||||||
|
}
|
||||||
|
|
||||||
private async persistClient(client: VpnClientDoc): Promise<void> {
|
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||||
await client.save();
|
await client.save();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface ITargetProfile {
|
|||||||
domains?: string[];
|
domains?: string[];
|
||||||
/** Specific IP:port targets this profile grants access to */
|
/** Specific IP:port targets this profile grants access to */
|
||||||
targets?: ITargetProfileTarget[];
|
targets?: ITargetProfileTarget[];
|
||||||
/** Route references by stored route ID or route name */
|
/** Route references by stored route ID. Legacy route names are normalized when unique. */
|
||||||
routeRefs?: string[];
|
routeRefs?: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ export interface IMigrationRunner {
|
|||||||
run(): Promise<IMigrationRunResult>;
|
run(): Promise<IMigrationRunResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateTargetProfileTargetHosts(ctx: {
|
||||||
|
mongo?: { collection: (name: string) => any };
|
||||||
|
log: { log: (level: 'info', message: string) => void };
|
||||||
|
}): Promise<void> {
|
||||||
|
const collection = ctx.mongo!.collection('TargetProfileDoc');
|
||||||
|
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||||
|
let migrated = 0;
|
||||||
|
|
||||||
|
for await (const doc of cursor) {
|
||||||
|
const targets = ((doc as any).targets || []).map((target: any) => {
|
||||||
|
if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) {
|
||||||
|
const { host, ...rest } = target;
|
||||||
|
return { ...rest, ip: host };
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
|
||||||
|
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||||
*
|
*
|
||||||
@@ -48,23 +72,7 @@ export async function createMigrationRunner(
|
|||||||
.step('rename-target-profile-host-to-ip')
|
.step('rename-target-profile-host-to-ip')
|
||||||
.from('13.0.11').to('13.1.0')
|
.from('13.0.11').to('13.1.0')
|
||||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||||
.up(async (ctx) => {
|
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
|
||||||
const collection = ctx.mongo!.collection('targetprofiledoc');
|
|
||||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
|
||||||
let migrated = 0;
|
|
||||||
for await (const doc of cursor) {
|
|
||||||
const targets = ((doc as any).targets || []).map((t: any) => {
|
|
||||||
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
|
|
||||||
const { host, ...rest } = t;
|
|
||||||
return { ...rest, ip: host };
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
|
||||||
migrated++;
|
|
||||||
}
|
|
||||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
|
||||||
})
|
|
||||||
.step('rename-domain-source-manual-to-dcrouter')
|
.step('rename-domain-source-manual-to-dcrouter')
|
||||||
.from('13.1.0').to('13.8.1')
|
.from('13.1.0').to('13.8.1')
|
||||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||||
@@ -120,6 +128,12 @@ export async function createMigrationRunner(
|
|||||||
await db.collection('RouteOverrideDoc').drop();
|
await db.collection('RouteOverrideDoc').drop();
|
||||||
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
|
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.step('repair-target-profile-ip-migration')
|
||||||
|
.from('13.16.0').to('13.17.4')
|
||||||
|
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
|
||||||
|
.up(async (ctx) => {
|
||||||
|
await migrateTargetProfileTargetHosts(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
return migration;
|
return migration;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.17.3',
|
version: '13.17.9',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:Plug',
|
icon: 'lucide:Plug',
|
||||||
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
||||||
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
|
description: `Total: ${this.formatNumber(this.statsState.serverStats?.totalConnections || 0)} connections`,
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'View Details',
|
name: 'View Details',
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
||||||
: '-',
|
: '-',
|
||||||
'Route Refs': profile.routeRefs?.length
|
'Route Refs': profile.routeRefs?.length
|
||||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
|
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
|
||||||
: '-',
|
: '-',
|
||||||
Created: new Date(profile.createdAt).toLocaleDateString(),
|
Created: new Date(profile.createdAt).toLocaleDateString(),
|
||||||
})}
|
})}
|
||||||
@@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRouteCandidates() {
|
private getRouteChoices() {
|
||||||
const routeState = appstate.routeManagementStatePart.getState();
|
const routeState = appstate.routeManagementStatePart.getState();
|
||||||
const routes = routeState?.mergedRoutes || [];
|
const routes = routeState?.mergedRoutes || [];
|
||||||
return routes
|
return routes
|
||||||
.filter((mr) => mr.route.name)
|
.filter((mr) => mr.route.name && mr.id)
|
||||||
.map((mr) => ({ viewKey: mr.route.name! }));
|
.map((mr) => ({
|
||||||
|
routeId: mr.id!,
|
||||||
|
routeName: mr.route.name!,
|
||||||
|
label: `${mr.route.name} (${mr.id})`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRouteCandidates() {
|
||||||
|
return this.getRouteChoices().map((route) => ({ viewKey: route.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined {
|
||||||
|
if (!routeRefs?.length) return undefined;
|
||||||
|
|
||||||
|
const routeChoices = this.getRouteChoices();
|
||||||
|
const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label]));
|
||||||
|
const routeByName = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (const route of routeChoices) {
|
||||||
|
const labels = routeByName.get(route.routeName) || [];
|
||||||
|
labels.push(route.label);
|
||||||
|
routeByName.set(route.routeName, labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
return routeRefs.map((routeRef) => {
|
||||||
|
const routeLabel = routeById.get(routeRef);
|
||||||
|
if (routeLabel) return routeLabel;
|
||||||
|
|
||||||
|
const labelsForName = routeByName.get(routeRef) || [];
|
||||||
|
if (labelsForName.length === 1) return labelsForName[0];
|
||||||
|
|
||||||
|
return routeRef;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRouteLabelsToRefs(routeRefs: string[]): string[] {
|
||||||
|
if (!routeRefs.length) return [];
|
||||||
|
|
||||||
|
const labelToId = new Map(
|
||||||
|
this.getRouteChoices().map((route) => [route.label, route.routeId]),
|
||||||
|
);
|
||||||
|
return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatRouteRef(routeRef: string): string {
|
||||||
|
return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureRoutesLoaded() {
|
private async ensureRoutesLoaded() {
|
||||||
@@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
const routeRefs = this.resolveRouteLabelsToRefs(
|
||||||
|
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
||||||
|
);
|
||||||
|
|
||||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
||||||
name: String(data.name),
|
name: String(data.name),
|
||||||
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||||
const currentDomains = profile.domains || [];
|
const currentDomains = profile.domains || [];
|
||||||
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
||||||
const currentRouteRefs = profile.routeRefs || [];
|
const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || [];
|
||||||
|
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
await this.ensureRoutesLoaded();
|
await this.ensureRoutesLoaded();
|
||||||
@@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
const routeRefs = this.resolveRouteLabelsToRefs(
|
||||||
|
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
||||||
|
);
|
||||||
|
|
||||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
@@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
|
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
|
||||||
<div style="font-size: 14px; margin-top: 4px;">
|
<div style="font-size: 14px; margin-top: 4px;">
|
||||||
${profile.routeRefs?.length
|
${profile.routeRefs?.length
|
||||||
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
|
? profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)
|
||||||
: '-'}
|
: '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
|
|||||||
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
||||||
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
||||||
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
|
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
|
||||||
if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
|
if (hostIpGroup) hostIpGroup.style.display = show;
|
||||||
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
|
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
|
||||||
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
||||||
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
||||||
@@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
if (!form) return;
|
if (!form) return;
|
||||||
const data = await form.collectFormData();
|
const data = await form.collectFormData();
|
||||||
if (!data.clientId) return;
|
if (!data.clientId) return;
|
||||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
targetProfileIds,
|
targetProfileIds,
|
||||||
|
|
||||||
useHostIp: useHostIp || undefined,
|
useHostIp,
|
||||||
useDhcp: useDhcp || undefined,
|
useDhcp,
|
||||||
staticIp,
|
staticIp,
|
||||||
forceVlan: forceVlan || undefined,
|
forceVlan,
|
||||||
vlanId,
|
vlanId,
|
||||||
destinationAllowList,
|
destinationAllowList,
|
||||||
destinationBlockList,
|
destinationBlockList,
|
||||||
@@ -485,7 +485,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
||||||
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
||||||
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
|
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
|
||||||
${client.useHostIp ? html`
|
${client.useHostIp ? html`
|
||||||
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
|
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
|
||||||
@@ -649,7 +649,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
const client = actionData.item as interfaces.data.IVpnClient;
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
const currentDescription = client.description ?? '';
|
const currentDescription = client.description ?? '';
|
||||||
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
|
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
|
||||||
const profileCandidates = this.getTargetProfileCandidates();
|
const profileCandidates = this.getTargetProfileCandidates();
|
||||||
const currentUseHostIp = client.useHostIp ?? false;
|
const currentUseHostIp = client.useHostIp ?? false;
|
||||||
const currentUseDhcp = client.useDhcp ?? false;
|
const currentUseDhcp = client.useDhcp ?? false;
|
||||||
@@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
const data = await form.collectFormData();
|
const data = await form.collectFormData();
|
||||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
targetProfileIds,
|
targetProfileIds,
|
||||||
|
|
||||||
useHostIp: useHostIp || undefined,
|
useHostIp,
|
||||||
useDhcp: useDhcp || undefined,
|
useDhcp,
|
||||||
staticIp,
|
staticIp,
|
||||||
forceVlan: forceVlan || undefined,
|
forceVlan,
|
||||||
vlanId,
|
vlanId,
|
||||||
destinationAllowList,
|
destinationAllowList,
|
||||||
destinationBlockList,
|
destinationBlockList,
|
||||||
@@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build autocomplete candidates from loaded target profiles.
|
* Build stable profile labels for list inputs.
|
||||||
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
|
|
||||||
*/
|
*/
|
||||||
private getTargetProfileCandidates() {
|
private getTargetProfileChoices() {
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
const profileState = appstate.targetProfilesStatePart.getState();
|
||||||
const profiles = profileState?.profiles || [];
|
const profiles = profileState?.profiles || [];
|
||||||
return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
|
const nameCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
nameCounts.set(profile.name, (nameCounts.get(profile.name) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles.map((profile) => ({
|
||||||
|
id: profile.id,
|
||||||
|
label: (nameCounts.get(profile.name) || 0) > 1
|
||||||
|
? `${profile.name} (${profile.id})`
|
||||||
|
: profile.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTargetProfileCandidates() {
|
||||||
|
return this.getTargetProfileChoices().map((profile) => ({ viewKey: profile.label }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert profile IDs to profile names (for populating edit form values).
|
* Convert profile IDs to form labels (for populating edit form values).
|
||||||
*/
|
*/
|
||||||
private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
|
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
|
||||||
if (!ids?.length) return undefined;
|
if (!ids?.length) return undefined;
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
const choices = this.getTargetProfileChoices();
|
||||||
const profiles = profileState?.profiles || [];
|
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
|
||||||
return ids.map((id) => {
|
return ids.map((id) => {
|
||||||
const profile = profiles.find((p) => p.id === id);
|
return labelsById.get(id) || id;
|
||||||
return profile?.name || id;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert profile names back to IDs (for saving form data).
|
* Convert profile form labels back to IDs.
|
||||||
* Uses the dees-input-list candidates' payload when available.
|
|
||||||
*/
|
*/
|
||||||
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
|
private resolveProfileLabelsToIds(labels: string[]): string[] {
|
||||||
if (!names.length) return undefined;
|
if (!labels.length) return [];
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
|
||||||
const profiles = profileState?.profiles || [];
|
const labelsToIds = new Map(
|
||||||
return names
|
this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
|
||||||
.map((name) => {
|
);
|
||||||
const profile = profiles.find((p) => p.name === name);
|
return labels
|
||||||
return profile?.id;
|
.map((label) => labelsToIds.get(label))
|
||||||
})
|
|
||||||
.filter((id): id is string => !!id);
|
.filter((id): id is string => !!id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user