Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2994b6e686 | |||
| ba15c169d7 | |||
| bbd5707711 | |||
| 1ddf83b28d | |||
| 25365678e0 | |||
| 96d215fc66 | |||
| 648ba9e61d | |||
| fcc1d9fede | |||
| 336e8aa4cc | |||
| c8f19cf783 | |||
| 12b2cc11da | |||
| ffcc35be64 | |||
| 59e0d41bdb | |||
| 9509d87b1e | |||
| b835e2d0eb | |||
| 6c3d8714a2 | |||
| 94f53f0259 | |||
| 1004f8579f | |||
| a77ec6884a | |||
| 6112e4e884 |
68
changelog.md
68
changelog.md
@@ -1,5 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-05 - 13.0.1 - fix(deps)
|
||||
bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies
|
||||
|
||||
- updates @design.estate/dees-catalog from ^3.55.6 to ^3.59.1
|
||||
- updates @push.rocks/smartdb from ^2.3.1 to ^2.4.1
|
||||
|
||||
## 2026-04-05 - 13.0.0 - BREAKING CHANGE(vpn)
|
||||
replace tag-based VPN access control with source and target profiles
|
||||
|
||||
- Renames Security Profiles to Source Profiles across APIs, persistence, route metadata, tests, and UI.
|
||||
- Adds TargetProfile management, storage, API handlers, and dashboard views to define VPN-accessible domains, targets, and route references.
|
||||
- Replaces route-level vpn configuration with vpnOnly and switches VPN clients from serverDefinedClientTags to targetProfileIds for access resolution.
|
||||
- Updates route application and VPN AllowedIPs generation to derive client access from matching target profiles instead of tags.
|
||||
|
||||
## 2026-04-04 - 12.10.0 - feat(routes)
|
||||
add TLS configuration controls for route create and edit flows
|
||||
|
||||
- Adds TLS mode and certificate selection to the route create and edit dialogs, including support for custom PEM key/certificate input.
|
||||
- Allows route updates to explicitly remove nested TLS settings by treating null action properties as deletions during route patch merging.
|
||||
- Bumps @design.estate/dees-catalog to ^3.55.6 and @serve.zone/catalog to ^2.11.1.
|
||||
|
||||
## 2026-04-04 - 12.9.4 - fix(deps)
|
||||
bump @push.rocks/smartdb to ^2.3.1
|
||||
|
||||
- updates the @push.rocks/smartdb dependency from ^2.1.1 to ^2.3.1
|
||||
|
||||
## 2026-04-04 - 12.9.3 - fix(route-management)
|
||||
include stored VPN routes in domain resolution and align programmatic route types with dcrouter configs
|
||||
|
||||
- Scans enabled stored/programmatic routes for VPN domain matches when resolving client access domains.
|
||||
- Replaces generic smartproxy route typings with IDcRouterRouteConfig across route management and stored route models.
|
||||
- Updates @push.rocks/smartproxy to ^27.4.0.
|
||||
|
||||
## 2026-04-04 - 12.9.2 - fix(config-ui)
|
||||
handle missing HTTP/3 config safely and standardize overview section headings
|
||||
|
||||
- Prevents route augmentation logic from failing when HTTP/3 configuration is undefined by using optional chaining.
|
||||
- Updates the operations overview to use dees-heading components for activity, email, DNS, RADIUS, and VPN section headings.
|
||||
- Bumps @push.rocks/smartproxy from ^27.2.0 to ^27.3.1.
|
||||
|
||||
## 2026-04-04 - 12.9.1 - fix(monitoring)
|
||||
update SmartProxy and use direct connection protocol metrics access
|
||||
|
||||
- bump @push.rocks/smartproxy from ^27.1.0 to ^27.2.0
|
||||
- replace fallback any-based access with direct frontend and backend protocol metric calls in MetricsManager
|
||||
|
||||
## 2026-04-04 - 12.9.0 - feat(monitoring)
|
||||
add frontend and backend protocol distribution metrics to network stats
|
||||
|
||||
- Expose frontend and backend protocol distribution data in monitoring metrics, stats responses, and shared interfaces.
|
||||
- Render protocol distribution donut charts in the ops network view using the new stats fields.
|
||||
- Preserve existing stored certificate IDs when updating certificate records by domain.
|
||||
- Bump @design.estate/dees-catalog to ^3.55.5 for the new chart component support.
|
||||
|
||||
## 2026-04-04 - 12.8.1 - fix(ops-view-routes)
|
||||
correct route form dropdown selection handling for security profiles and network targets
|
||||
|
||||
- Update route edit and create forms to use selectedOption for dropdowns backed by the newer dees-catalog version
|
||||
- Normalize submitted dropdown values to extract option keys before storing securityProfileRef and networkTargetRef
|
||||
- Refresh documentation to reflect expanded stats coverage for network, RADIUS, and VPN metrics
|
||||
|
||||
## 2026-04-03 - 12.8.0 - feat(certificates)
|
||||
add force renew option for domain certificate reprovisioning
|
||||
|
||||
- pass an optional forceRenew flag through certificate reprovision requests from the UI to the ops handler
|
||||
- use smartacme forceRenew support and return renewal-specific success messages
|
||||
- update the SmartAcme dependency to version ^9.4.0
|
||||
|
||||
## 2026-04-03 - 12.7.0 - feat(opsserver)
|
||||
add RADIUS and VPN metrics to combined ops stats and overview dashboards, and stream live log buffer entries in follow mode
|
||||
|
||||
|
||||
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "12.7.0",
|
||||
"version": "13.0.1",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -35,14 +35,14 @@
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.52.3",
|
||||
"@design.estate/dees-catalog": "^3.59.1",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.3.1",
|
||||
"@push.rocks/smartacme": "^9.4.0",
|
||||
"@push.rocks/smartdata": "^7.1.3",
|
||||
"@push.rocks/smartdb": "^2.1.1",
|
||||
"@push.rocks/smartdb": "^2.4.1",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
@@ -53,7 +53,7 @@
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.1.0",
|
||||
"@push.rocks/smartproxy": "^27.4.0",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.1",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.11.0",
|
||||
"@serve.zone/catalog": "^2.11.1",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
|
||||
96
pnpm-lock.yaml
generated
96
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^3.52.3
|
||||
version: 3.52.3(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.59.1
|
||||
version: 3.59.1(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
@@ -39,14 +39,14 @@ importers:
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
'@push.rocks/smartacme':
|
||||
specifier: ^9.3.1
|
||||
version: 9.3.1(socks@2.8.7)
|
||||
specifier: ^9.4.0
|
||||
version: 9.4.0(socks@2.8.7)
|
||||
'@push.rocks/smartdata':
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3(socks@2.8.7)
|
||||
'@push.rocks/smartdb':
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1(@tiptap/pm@2.27.2)
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1(@tiptap/pm@2.27.2)
|
||||
'@push.rocks/smartdns':
|
||||
specifier: ^7.9.0
|
||||
version: 7.9.0
|
||||
@@ -78,8 +78,8 @@ importers:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.1.0
|
||||
version: 27.1.0
|
||||
specifier: ^27.4.0
|
||||
version: 27.4.0
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -102,8 +102,8 @@ importers:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
'@serve.zone/catalog':
|
||||
specifier: ^2.11.0
|
||||
version: 2.11.0(@tiptap/pm@2.27.2)
|
||||
specifier: ^2.11.1
|
||||
version: 2.11.1(@tiptap/pm@2.27.2)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -350,8 +350,8 @@ packages:
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
'@design.estate/dees-catalog@3.52.3':
|
||||
resolution: {integrity: sha512-4/vybRZQtdkpa3IOZQ/EbL6gGjIMO+430db43RRfdw+HPXird7Jl+oIXz6pDh+rGah2H2+Srb/c+eve46xAhtQ==}
|
||||
'@design.estate/dees-catalog@3.59.1':
|
||||
resolution: {integrity: sha512-UE4d7VgUvxLgqn+LxHFroGdJ4S+pYOFToWYl/XynUa+ceqT7+bBfJ8DJ6yTGwbXPogQV2HyEYtM2Hu1p4DfYPQ==}
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||
@@ -1108,8 +1108,8 @@ packages:
|
||||
'@push.rocks/qenv@6.1.3':
|
||||
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
|
||||
|
||||
'@push.rocks/smartacme@9.3.1':
|
||||
resolution: {integrity: sha512-Cl1DVQ+rfpaYkk6VVm/KYVeUYzWfXzSfTXybHfCZ5SuiACuTVHZ6jK8TouELaV1RgrdYnIp0MrbiY2Kqi8ayAw==}
|
||||
'@push.rocks/smartacme@9.4.0':
|
||||
resolution: {integrity: sha512-mSqsI859mHI9fCZxLfayzPf/WvukDFzVHOh02vXq3ujxbb5M+ArMnXe0MmC2egR9GeXmQTm3DTENaETX5ffMtw==}
|
||||
|
||||
'@push.rocks/smartarchive@4.2.4':
|
||||
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
|
||||
@@ -1141,8 +1141,8 @@ packages:
|
||||
'@push.rocks/smartdata@7.1.3':
|
||||
resolution: {integrity: sha512-7vQJ9pdRk450yn2m9tmGPdSRlQVmxFPZjHD4sGYsfqCQPg+GLFusu+H16zpf+jKzAq4F2ZBMPaYymJHXvXiVcw==}
|
||||
|
||||
'@push.rocks/smartdb@2.1.1':
|
||||
resolution: {integrity: sha512-bm+xYpuzSgS+EacNP3NppwNvpw9OZN3gmtVUgBdqyLLKYX0329bDN5X63V6vdrglFBV/+MKox43l8BQBwVdfjw==}
|
||||
'@push.rocks/smartdb@2.4.1':
|
||||
resolution: {integrity: sha512-9nD+i629FB8C+Guuxvh7l4TqQptc7GTR7YijP7Q29Vua5HHeETbjszFPD/Uu+/EnVhE8lqU/Zm0GsFdaloiDjw==}
|
||||
|
||||
'@push.rocks/smartdelay@3.0.5':
|
||||
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
|
||||
@@ -1279,8 +1279,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.3':
|
||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||
|
||||
'@push.rocks/smartproxy@27.1.0':
|
||||
resolution: {integrity: sha512-uMtmbT6/9Y+lOnSi4w6SRICWJr9q9bHsYAq6xMLmym3zvnEzEwJWF6sw4Jb/uEFEjI2/e4irNSQ9Ba74DhFRlg==}
|
||||
'@push.rocks/smartproxy@27.4.0':
|
||||
resolution: {integrity: sha512-r6ym6+FpiHHLFgCdY0KfCe8s2eLuLUU/daXIcKwsmMHNY3BOlBIxwN5CL17+v2Hvh7Lg2JWBY+1L7/7ehYXj1w==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5':
|
||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||
@@ -1583,8 +1583,8 @@ packages:
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
'@serve.zone/catalog@2.11.0':
|
||||
resolution: {integrity: sha512-4DFDewp1PFRhw5P+yQAoAw+i6gG2lfR3h+uPgbNxB5jCfW14eNDXi3nuwTMBQWRHL9jv8o0BokASjV9A0+q66g==}
|
||||
'@serve.zone/catalog@2.11.1':
|
||||
resolution: {integrity: sha512-KeeShLELKANWEAY3Z4h+Kx1bmatWLa0nvtw8wa7iHLxV6Vm3rRJNdRyh91ozuWPgFYMC48V8/4REA8zJsdDcbg==}
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||
@@ -2516,6 +2516,9 @@ packages:
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
echarts@5.6.0:
|
||||
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
@@ -2866,8 +2869,8 @@ packages:
|
||||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=}
|
||||
|
||||
ibantools@4.5.1:
|
||||
resolution: {integrity: sha512-DfKQpLlFq9yEUIEnFuCJzss3XavD7iHZTU5PyqXiAJ+rmaMp+NFP3hboumHKuK8nZjuOJg93WemTzcQ5b9jOZA==}
|
||||
ibantools@4.5.2:
|
||||
resolution: {integrity: sha512-is+8TgZcKS/AMv/z9nW1zz0bhjhoyjpA1p0nc3A6GkW/InOdcQiUZpkufADzh/aO/LY/TOD/P3oPWncNRn5QMA==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
@@ -3300,6 +3303,10 @@ packages:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
@@ -4068,6 +4075,9 @@ packages:
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -4315,6 +4325,9 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zrender@5.6.1:
|
||||
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
@@ -4342,7 +4355,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
||||
'@cloudflare/workers-types': 4.20260317.1
|
||||
'@design.estate/dees-catalog': 3.52.3(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.59.1(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.4.0
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -4871,7 +4884,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
'@design.estate/dees-catalog@3.52.3(@tiptap/pm@2.27.2)':
|
||||
'@design.estate/dees-catalog@3.59.1(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
@@ -4891,8 +4904,9 @@ snapshots:
|
||||
'@tiptap/extension-underline': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
|
||||
'@tiptap/starter-kit': 2.27.2
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
echarts: 5.6.0
|
||||
highlight.js: 11.11.1
|
||||
ibantools: 4.5.1
|
||||
ibantools: 4.5.2
|
||||
lightweight-charts: 5.1.0
|
||||
lucide: 0.577.0
|
||||
monaco-editor: 0.55.1
|
||||
@@ -5960,7 +5974,7 @@ snapshots:
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
|
||||
'@push.rocks/smartacme@9.3.1(socks@2.8.7)':
|
||||
'@push.rocks/smartacme@9.4.0(socks@2.8.7)':
|
||||
dependencies:
|
||||
'@apiclient.xyz/cloudflare': 7.1.0
|
||||
'@peculiar/x509': 2.0.0
|
||||
@@ -6130,11 +6144,12 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/smartdb@2.1.1(@tiptap/pm@2.27.2)':
|
||||
'@push.rocks/smartdb@2.4.1(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@api.global/typedserver': 8.4.6(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
bson: 7.2.0
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- '@tiptap/pm'
|
||||
@@ -6508,14 +6523,14 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.3': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.1.0':
|
||||
'@push.rocks/smartproxy@27.4.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartnftables': 1.0.1
|
||||
'@push.rocks/smartnftables': 1.1.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
minimatch: 10.2.4
|
||||
minimatch: 10.2.5
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5(typescript@6.0.2)':
|
||||
dependencies:
|
||||
@@ -6907,9 +6922,9 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
selderee: 0.11.0
|
||||
|
||||
'@serve.zone/catalog@2.11.0(@tiptap/pm@2.27.2)':
|
||||
'@serve.zone/catalog@2.11.1(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-catalog': 3.52.3(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.59.1(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
@@ -7978,6 +7993,11 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
echarts@5.6.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 5.6.1
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
encoding-japanese@2.2.0: {}
|
||||
@@ -8410,7 +8430,7 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
ibantools@4.5.1: {}
|
||||
ibantools@4.5.2: {}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
@@ -9048,6 +9068,10 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.5
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.5
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.13
|
||||
@@ -9942,6 +9966,8 @@ snapshots:
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
@@ -10169,4 +10195,8 @@ snapshots:
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zrender@5.6.1:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -25,7 +25,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- [Remote Ingress](#remote-ingress)
|
||||
- [VPN Access Control](#vpn-access-control)
|
||||
- [Certificate Management](#certificate-management)
|
||||
- [Storage & Caching](#storage--caching)
|
||||
- [Storage & Database](#storage--database)
|
||||
- [Security Features](#security-features)
|
||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||
- [API Client](#api-client)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||
import type { ISecurityProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers: access private maps for direct unit testing without DB
|
||||
// ============================================================================
|
||||
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISecurityProfile): void {
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||
(resolver as any).profiles.set(profile.id, profile);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void
|
||||
(resolver as any).targets.set(target.id, target);
|
||||
}
|
||||
|
||||
function makeProfile(overrides: Partial<ISecurityProfile> = {}): ISecurityProfile {
|
||||
function makeProfile(overrides: Partial<ISourceProfile> = {}): ISourceProfile {
|
||||
return {
|
||||
id: 'profile-1',
|
||||
name: 'STANDARD',
|
||||
@@ -72,14 +72,14 @@ tap.test('should list empty profiles and targets initially', async () => {
|
||||
expect(resolver.listTargets().length).toEqual(0);
|
||||
});
|
||||
|
||||
// ---- Security profile resolution ----
|
||||
// ---- Source profile resolution ----
|
||||
|
||||
tap.test('should resolve security profile onto a route', async () => {
|
||||
tap.test('should resolve source profile onto a route', async () => {
|
||||
const profile = makeProfile();
|
||||
injectProfile(resolver, profile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
@@ -87,7 +87,7 @@ tap.test('should resolve security profile onto a route', async () => {
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
expect(result.metadata.securityProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ tap.test('should merge inline route security with profile security', async () =>
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
@@ -117,7 +117,7 @@ tap.test('should deduplicate IP lists during merge', async () => {
|
||||
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
@@ -128,13 +128,13 @@ tap.test('should deduplicate IP lists during merge', async () => {
|
||||
|
||||
tap.test('should handle missing profile gracefully', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be unchanged
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.securityProfileName).toBeUndefined();
|
||||
expect(result.metadata.sourceProfileName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Profile inheritance ----
|
||||
@@ -161,7 +161,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
injectProfile(resolver, extendedProfile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'extended-profile' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
@@ -170,7 +170,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||
// maxConnections from base (extended doesn't override)
|
||||
expect(result.route.security!.maxConnections).toEqual(500);
|
||||
expect(result.metadata.securityProfileName).toEqual('EXTENDED');
|
||||
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
|
||||
});
|
||||
|
||||
tap.test('should detect circular profile inheritance', async () => {
|
||||
@@ -190,7 +190,7 @@ tap.test('should detect circular profile inheritance', async () => {
|
||||
injectProfile(resolver, profileB);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { securityProfileRef: 'circular-a' };
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
|
||||
|
||||
// Should not infinite loop — resolves what it can
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
@@ -232,7 +232,7 @@ tap.test('should handle missing target gracefully', async () => {
|
||||
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
securityProfileRef: 'profile-1',
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
@@ -247,7 +247,7 @@ tap.test('should resolve both profile and target simultaneously', async () => {
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
|
||||
// Both names recorded
|
||||
expect(result.metadata.securityProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
});
|
||||
|
||||
@@ -268,7 +268,7 @@ tap.test('should skip resolution when no metadata refs', async () => {
|
||||
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
securityProfileRef: 'profile-1',
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
@@ -288,7 +288,7 @@ tap.test('should find routes by profile ref (sync)', async () => {
|
||||
id: 'route-a',
|
||||
route: makeRoute({ name: 'route-a' }),
|
||||
enabled: true,
|
||||
metadata: { securityProfileRef: 'profile-1' },
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
});
|
||||
storedRoutes.set('route-b', {
|
||||
id: 'route-b',
|
||||
@@ -300,7 +300,7 @@ tap.test('should find routes by profile ref (sync)', async () => {
|
||||
id: 'route-c',
|
||||
route: makeRoute({ name: 'route-c' }),
|
||||
enabled: true,
|
||||
metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
});
|
||||
|
||||
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||
@@ -320,7 +320,7 @@ tap.test('should get profile usage for a specific profile ID', async () => {
|
||||
id: 'route-x',
|
||||
route: makeRoute({ name: 'my-route' }),
|
||||
enabled: true,
|
||||
metadata: { securityProfileRef: 'profile-1' },
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
});
|
||||
|
||||
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||
|
||||
@@ -39,13 +39,13 @@ tap.test('should login as admin', async () => {
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Security Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||
// Source Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should return empty profiles list when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||
TEST_URL,
|
||||
'getSecurityProfiles'
|
||||
'getSourceProfiles'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
@@ -57,9 +57,9 @@ tap.test('should return empty profiles list when resolver not initialized', asyn
|
||||
});
|
||||
|
||||
tap.test('should return null for single profile when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfile>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfile>(
|
||||
TEST_URL,
|
||||
'getSecurityProfile'
|
||||
'getSourceProfile'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
@@ -71,9 +71,9 @@ tap.test('should return null for single profile when resolver not initialized',
|
||||
});
|
||||
|
||||
tap.test('should return failure for create profile when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_CreateSourceProfile>(
|
||||
TEST_URL,
|
||||
'createSecurityProfile'
|
||||
'createSourceProfile'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
@@ -87,9 +87,9 @@ tap.test('should return failure for create profile when resolver not initialized
|
||||
});
|
||||
|
||||
tap.test('should return empty profile usage when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfileUsage>(
|
||||
TEST_URL,
|
||||
'getSecurityProfileUsage'
|
||||
'getSourceProfileUsage'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
@@ -170,9 +170,9 @@ tap.test('should return empty target usage when resolver not initialized', async
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should reject unauthenticated profile requests', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||
TEST_URL,
|
||||
'getSecurityProfiles'
|
||||
'getSourceProfiles'
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -29,13 +29,13 @@ const devRouter = new DcRouter({
|
||||
name: 'vpn-internal-app',
|
||||
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||
vpn: { enabled: true },
|
||||
vpnOnly: true,
|
||||
},
|
||||
{
|
||||
name: 'vpn-eng-dashboard',
|
||||
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||
vpnOnly: true,
|
||||
},
|
||||
] as any[],
|
||||
},
|
||||
@@ -44,9 +44,9 @@ const devRouter = new DcRouter({
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.dev.local',
|
||||
clients: [
|
||||
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' },
|
||||
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' },
|
||||
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' },
|
||||
{ clientId: 'dev-laptop', description: 'Developer laptop' },
|
||||
{ clientId: 'ci-runner', description: 'CI/CD pipeline' },
|
||||
{ clientId: 'admin-desktop', description: 'Admin workstation' },
|
||||
],
|
||||
},
|
||||
dbConfig: { enabled: true },
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '12.7.0',
|
||||
version: '13.0.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder } from './config/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
|
||||
@@ -180,8 +180,8 @@ export interface IDcRouterOptions {
|
||||
|
||||
/**
|
||||
* VPN server configuration.
|
||||
* Enables VPN-based access control: routes with vpn.enabled are only
|
||||
* accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports.
|
||||
* Enables VPN-based access control: routes with vpnOnly are only
|
||||
* accessible from VPN clients whose TargetProfile matches. Supports WireGuard + native (WS/QUIC) transports.
|
||||
*/
|
||||
vpnConfig?: {
|
||||
/** Enable VPN server (default: false) */
|
||||
@@ -197,7 +197,7 @@ export interface IDcRouterOptions {
|
||||
/** Pre-defined VPN clients created on startup */
|
||||
clients?: Array<{
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
}>;
|
||||
/** Destination routing policy for VPN client traffic.
|
||||
@@ -274,6 +274,7 @@ export class DcRouter {
|
||||
public routeConfigManager?: RouteConfigManager;
|
||||
public apiTokenManager?: ApiTokenManager;
|
||||
public referenceResolver?: ReferenceResolver;
|
||||
public targetProfileManager?: TargetProfileManager;
|
||||
|
||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||
public detectedPublicIp: string | null = null;
|
||||
@@ -465,16 +466,22 @@ export class DcRouter {
|
||||
this.referenceResolver = new ReferenceResolver();
|
||||
await this.referenceResolver.initialize();
|
||||
|
||||
// Initialize target profile manager
|
||||
this.targetProfileManager = new TargetProfileManager();
|
||||
await this.targetProfileManager.initialize();
|
||||
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.options.vpnConfig?.enabled
|
||||
? (tags?: string[]) => {
|
||||
if (tags?.length && this.vpnManager) {
|
||||
return this.vpnManager.getClientIpsForServerDefinedTags(tags);
|
||||
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
|
||||
if (!this.vpnManager || !this.targetProfileManager) {
|
||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||
}
|
||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route, routeId, this.vpnManager.listClients(),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
this.referenceResolver,
|
||||
@@ -504,6 +511,7 @@ export class DcRouter {
|
||||
this.routeConfigManager = undefined;
|
||||
this.apiTokenManager = undefined;
|
||||
this.referenceResolver = undefined;
|
||||
this.targetProfileManager = undefined;
|
||||
})
|
||||
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
|
||||
);
|
||||
@@ -2137,36 +2145,31 @@ export class DcRouter {
|
||||
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
|
||||
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||
onClientChanged: () => {
|
||||
// Re-apply routes so tag-based ipAllowLists get updated
|
||||
// Re-apply routes so profile-based ipAllowLists get updated
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
},
|
||||
getClientAllowedIPs: async (clientTags: string[]) => {
|
||||
getClientAllowedIPs: async (targetProfileIds: string[]) => {
|
||||
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||
const ips = new Set<string>([subnet]);
|
||||
|
||||
// Check routes for VPN-gated tag match and collect domains
|
||||
const routes = this.options.smartProxyConfig?.routes || [];
|
||||
const domainsToResolve = new Set<string>();
|
||||
for (const route of routes) {
|
||||
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpn?.enabled) continue;
|
||||
if (!this.targetProfileManager) return [...ips];
|
||||
|
||||
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags;
|
||||
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) {
|
||||
// Collect domains from this route
|
||||
const domains = (route.match as any)?.domains;
|
||||
if (Array.isArray(domains)) {
|
||||
for (const d of domains) {
|
||||
// Strip wildcard prefix for DNS resolution (*.example.com → example.com)
|
||||
domainsToResolve.add(d.replace(/^\*\./, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[];
|
||||
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
|
||||
|
||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||
targetProfileIds, routes, storedRoutes,
|
||||
);
|
||||
|
||||
// Add target IPs directly
|
||||
for (const ip of targetIps) {
|
||||
ips.add(`${ip}/32`);
|
||||
}
|
||||
|
||||
// Resolve DNS A records for matched domains (with caching)
|
||||
for (const domain of domainsToResolve) {
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||
for (const domain of domains) {
|
||||
const stripped = domain.replace(/^\*\./, '');
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
||||
for (const ip of resolvedIps) {
|
||||
ips.add(`${ip}/32`);
|
||||
}
|
||||
@@ -2179,7 +2182,7 @@ export class DcRouter {
|
||||
await this.vpnManager.start();
|
||||
|
||||
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
||||
// get correct tag-based ipAllowLists (not possible during setupSmartProxy since
|
||||
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
|
||||
// VPN server wasn't ready yet)
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ export class StorageBackedCertManager implements plugins.smartacme.ICertManager
|
||||
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
|
||||
if (!doc) {
|
||||
doc = new AcmeCertDoc();
|
||||
doc.id = cert.id;
|
||||
doc.domainName = cert.domainName;
|
||||
}
|
||||
doc.id = cert.id;
|
||||
doc.created = cert.created;
|
||||
doc.privateKey = cert.privateKey;
|
||||
doc.publicKey = cert.publicKey;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
ISecurityProfile,
|
||||
ISourceProfile,
|
||||
INetworkTarget,
|
||||
IRouteMetadata,
|
||||
IStoredRoute,
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
const MAX_INHERITANCE_DEPTH = 5;
|
||||
|
||||
export class ReferenceResolver {
|
||||
private profiles = new Map<string, ISecurityProfile>();
|
||||
private profiles = new Map<string, ISourceProfile>();
|
||||
private targets = new Map<string, INetworkTarget>();
|
||||
|
||||
// =========================================================================
|
||||
@@ -38,7 +38,7 @@ export class ReferenceResolver {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const profile: ISecurityProfile = {
|
||||
const profile: ISourceProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
@@ -51,17 +51,17 @@ export class ReferenceResolver {
|
||||
|
||||
this.profiles.set(id, profile);
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Created security profile '${profile.name}' (${id})`);
|
||||
logger.log('info', `Created source profile '${profile.name}' (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateProfile(
|
||||
id: string,
|
||||
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
patch: Partial<Omit<ISourceProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
): Promise<{ affectedRouteIds: string[] }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
throw new Error(`Security profile '${id}' not found`);
|
||||
throw new Error(`Source profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
@@ -71,7 +71,7 @@ export class ReferenceResolver {
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Updated security profile '${profile.name}' (${id})`);
|
||||
logger.log('info', `Updated source profile '${profile.name}' (${id})`);
|
||||
|
||||
// Find routes referencing this profile
|
||||
const affectedRouteIds = await this.findRoutesByProfileRef(id);
|
||||
@@ -85,7 +85,7 @@ export class ReferenceResolver {
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
return { success: false, message: `Security profile '${id}' not found` };
|
||||
return { success: false, message: `Source profile '${id}' not found` };
|
||||
}
|
||||
|
||||
// Check usage
|
||||
@@ -101,7 +101,7 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
const doc = await SecurityProfileDoc.findById(id);
|
||||
const doc = await SourceProfileDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
this.profiles.delete(id);
|
||||
|
||||
@@ -110,24 +110,24 @@ export class ReferenceResolver {
|
||||
await this.clearProfileRefsOnRoutes(affectedIds);
|
||||
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||
} else {
|
||||
logger.log('info', `Deleted security profile '${profile.name}' (${id})`);
|
||||
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public getProfile(id: string): ISecurityProfile | undefined {
|
||||
public getProfile(id: string): ISourceProfile | undefined {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
public getProfileByName(name: string): ISecurityProfile | undefined {
|
||||
public getProfileByName(name: string): ISourceProfile | undefined {
|
||||
for (const profile of this.profiles.values()) {
|
||||
if (profile.name === name) return profile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public listProfiles(): ISecurityProfile[] {
|
||||
public listProfiles(): ISourceProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export class ReferenceResolver {
|
||||
usage.set(profile.id, []);
|
||||
}
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
const ref = stored.metadata?.securityProfileRef;
|
||||
const ref = stored.metadata?.sourceProfileRef;
|
||||
if (ref && usage.has(ref)) {
|
||||
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export class ReferenceResolver {
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.securityProfileRef === profileId) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@ export class ReferenceResolver {
|
||||
|
||||
/**
|
||||
* Resolve references for a single route.
|
||||
* Materializes security profile and/or network target into the route's fields.
|
||||
* Materializes source profile and/or network target into the route's fields.
|
||||
* Returns the resolved route and updated metadata.
|
||||
*/
|
||||
public resolveRoute(
|
||||
@@ -289,19 +289,19 @@ export class ReferenceResolver {
|
||||
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||
|
||||
if (resolvedMetadata.securityProfileRef) {
|
||||
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef);
|
||||
if (resolvedMetadata.sourceProfileRef) {
|
||||
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
|
||||
if (resolvedSecurity) {
|
||||
const profile = this.profiles.get(resolvedMetadata.securityProfileRef);
|
||||
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
|
||||
// Merge: profile provides base, route's inline values override
|
||||
route = {
|
||||
...route,
|
||||
security: this.mergeSecurityFields(resolvedSecurity, route.security),
|
||||
};
|
||||
resolvedMetadata.securityProfileName = profile?.name;
|
||||
resolvedMetadata.sourceProfileName = profile?.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
} else {
|
||||
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`);
|
||||
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ export class ReferenceResolver {
|
||||
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.securityProfileRef === profileId)
|
||||
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ export class ReferenceResolver {
|
||||
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.securityProfileRef === profileId) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
ids.push(routeId);
|
||||
}
|
||||
}
|
||||
@@ -367,10 +367,10 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: security profile resolution with inheritance
|
||||
// Private: source profile resolution with inheritance
|
||||
// =========================================================================
|
||||
|
||||
private resolveSecurityProfile(
|
||||
private resolveSourceProfile(
|
||||
profileId: string,
|
||||
visited: Set<string> = new Set(),
|
||||
depth: number = 0,
|
||||
@@ -396,7 +396,7 @@ export class ReferenceResolver {
|
||||
// Resolve parent profiles first (top-down, later overrides earlier)
|
||||
if (profile.extendsProfiles?.length) {
|
||||
for (const parentId of profile.extendsProfiles) {
|
||||
const parentSecurity = this.resolveSecurityProfile(parentId, new Set(visited), depth + 1);
|
||||
const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1);
|
||||
if (parentSecurity) {
|
||||
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
|
||||
}
|
||||
@@ -453,7 +453,7 @@ export class ReferenceResolver {
|
||||
// =========================================================================
|
||||
|
||||
private async loadProfiles(): Promise<void> {
|
||||
const docs = await SecurityProfileDoc.findAll();
|
||||
const docs = await SourceProfileDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.profiles.set(doc.id, {
|
||||
@@ -469,7 +469,7 @@ export class ReferenceResolver {
|
||||
}
|
||||
}
|
||||
if (this.profiles.size > 0) {
|
||||
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`);
|
||||
logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,8 +494,8 @@ export class ReferenceResolver {
|
||||
}
|
||||
}
|
||||
|
||||
private async persistProfile(profile: ISecurityProfile): Promise<void> {
|
||||
const existingDoc = await SecurityProfileDoc.findById(profile.id);
|
||||
private async persistProfile(profile: ISourceProfile): Promise<void> {
|
||||
const existingDoc = await SourceProfileDoc.findById(profile.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = profile.name;
|
||||
existingDoc.description = profile.description;
|
||||
@@ -504,7 +504,7 @@ export class ReferenceResolver {
|
||||
existingDoc.updatedAt = profile.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new SecurityProfileDoc();
|
||||
const doc = new SourceProfileDoc();
|
||||
doc.id = profile.id;
|
||||
doc.name = profile.name;
|
||||
doc.description = profile.description;
|
||||
@@ -550,8 +550,8 @@ export class ReferenceResolver {
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
securityProfileRef: undefined,
|
||||
securityProfileName: undefined,
|
||||
sourceProfileRef: undefined,
|
||||
sourceProfileName: undefined,
|
||||
};
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
|
||||
@@ -21,7 +21,7 @@ export class RouteConfigManager {
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => string[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||
) {}
|
||||
@@ -83,7 +83,7 @@ export class RouteConfigManager {
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
route: IDcRouterRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
metadata?: IRouteMetadata,
|
||||
@@ -123,7 +123,7 @@ export class RouteConfigManager {
|
||||
public async updateRoute(
|
||||
id: string,
|
||||
patch: {
|
||||
route?: Partial<plugins.smartproxy.IRouteConfig>;
|
||||
route?: Partial<IDcRouterRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
},
|
||||
@@ -132,7 +132,18 @@ export class RouteConfigManager {
|
||||
if (!stored) return false;
|
||||
|
||||
if (patch.route) {
|
||||
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
|
||||
const mergedAction = patch.route.action
|
||||
? { ...stored.route.action, ...patch.route.action }
|
||||
: stored.route.action;
|
||||
// Handle explicit null to remove nested action properties (e.g., tls: null)
|
||||
if (patch.route.action) {
|
||||
for (const [key, val] of Object.entries(patch.route.action)) {
|
||||
if (val === null) {
|
||||
delete (mergedAction as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
stored.route = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
stored.enabled = patch.enabled;
|
||||
@@ -352,22 +363,19 @@ export class RouteConfigManager {
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnAllowList = this.getVpnAllowList;
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
|
||||
// Helper: inject VPN security into a route if vpn.enabled is set
|
||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => {
|
||||
if (!vpnAllowList) return route;
|
||||
// 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.vpn?.enabled) return route;
|
||||
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
|
||||
const mandatory = dcRoute.vpn.mandatory === true; // defaults to false
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const allowList = vpnCallback(dcRoute, routeId);
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: mandatory
|
||||
? allowList
|
||||
: [...(route.security?.ipAllowList || []), ...allowList],
|
||||
ipAllowList: allowList,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -386,10 +394,10 @@ export class RouteConfigManager {
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config && http3Config.enabled !== false) {
|
||||
if (http3Config?.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
enabledRoutes.push(injectVpn(route, stored.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
348
ts/config/classes.target-profile-manager.ts
Normal file
348
ts/config/classes.target-profile-manager.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
|
||||
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
/**
|
||||
* Manages TargetProfiles (target-side: what can be accessed).
|
||||
* TargetProfiles define what resources a VPN client can reach:
|
||||
* domains, specific IP:port targets, and/or direct route references.
|
||||
*/
|
||||
export class TargetProfileManager {
|
||||
private profiles = new Map<string, ITargetProfile>();
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadProfiles();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createProfile(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: ITargetProfileTarget[];
|
||||
routeRefs?: string[];
|
||||
createdBy: string;
|
||||
}): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const profile: ITargetProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
domains: data.domains,
|
||||
targets: data.targets,
|
||||
routeRefs: data.routeRefs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
};
|
||||
|
||||
this.profiles.set(id, profile);
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Created target profile '${profile.name}' (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateProfile(
|
||||
id: string,
|
||||
patch: Partial<Omit<ITargetProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
): Promise<void> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
throw new Error(`Target profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
if (patch.description !== undefined) profile.description = patch.description;
|
||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Updated target profile '${profile.name}' (${id})`);
|
||||
}
|
||||
|
||||
public async deleteProfile(
|
||||
id: string,
|
||||
force?: boolean,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
return { success: false, message: `Target profile '${id}' not found` };
|
||||
}
|
||||
|
||||
// Check if any VPN clients reference this profile
|
||||
const clients = await VpnClientDoc.findAll();
|
||||
const referencingClients = clients.filter(
|
||||
(c) => c.targetProfileIds?.includes(id),
|
||||
);
|
||||
|
||||
if (referencingClients.length > 0 && !force) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Profile '${profile.name}' is in use by ${referencingClients.length} VPN client(s). Use force=true to delete.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
const doc = await TargetProfileDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
this.profiles.delete(id);
|
||||
|
||||
if (referencingClients.length > 0) {
|
||||
// Remove profile ref from clients
|
||||
for (const client of referencingClients) {
|
||||
client.targetProfileIds = client.targetProfileIds?.filter((pid) => pid !== id);
|
||||
client.updatedAt = Date.now();
|
||||
await client.save();
|
||||
}
|
||||
logger.log('warn', `Force-deleted target profile '${profile.name}'; removed refs from ${referencingClients.length} client(s)`);
|
||||
} else {
|
||||
logger.log('info', `Deleted target profile '${profile.name}' (${id})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public getProfile(id: string): ITargetProfile | undefined {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
public listProfiles(): ITargetProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which VPN clients reference a target profile.
|
||||
*/
|
||||
public async getProfileUsage(profileId: string): Promise<Array<{ clientId: string; description?: string }>> {
|
||||
const clients = await VpnClientDoc.findAll();
|
||||
return clients
|
||||
.filter((c) => c.targetProfileIds?.includes(profileId))
|
||||
.map((c) => ({ clientId: c.clientId, description: c.description }));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Core matching: route → client IPs
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
|
||||
* matches the route. Returns their assigned IPs for injection into ipAllowList.
|
||||
*/
|
||||
public getMatchingClientIps(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
clients: VpnClientDoc[],
|
||||
): string[] {
|
||||
const ips: string[] = [];
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
if (!client.targetProfileIds?.length) continue;
|
||||
|
||||
// Check if any of the client's profiles match this route
|
||||
const matches = client.targetProfileIds.some((profileId) => {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) return false;
|
||||
return this.routeMatchesProfile(route, routeId, profile);
|
||||
});
|
||||
|
||||
if (matches) {
|
||||
ips.push(client.assignedIp);
|
||||
}
|
||||
}
|
||||
|
||||
return ips;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given client (by its targetProfileIds), compute the set of
|
||||
* domains and target IPs it can access. Used for WireGuard AllowedIPs.
|
||||
*/
|
||||
public getClientAccessSpec(
|
||||
targetProfileIds: string[],
|
||||
allRoutes: IDcRouterRouteConfig[],
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
): { domains: string[]; targetIps: string[] } {
|
||||
const domains = new Set<string>();
|
||||
const targetIps = new Set<string>();
|
||||
|
||||
// Collect all access specifiers from assigned profiles
|
||||
for (const profileId of targetProfileIds) {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) continue;
|
||||
|
||||
// Direct domain entries
|
||||
if (profile.domains?.length) {
|
||||
for (const d of profile.domains) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct target IP entries
|
||||
if (profile.targets?.length) {
|
||||
for (const t of profile.targets) {
|
||||
targetIps.add(t.host);
|
||||
}
|
||||
}
|
||||
|
||||
// Route references: scan constructor routes
|
||||
for (const route of allRoutes) {
|
||||
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
|
||||
const routeDomains = (route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route references: scan stored routes
|
||||
for (const [storedId, stored] of storedRoutes) {
|
||||
if (!stored.enabled) continue;
|
||||
if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) {
|
||||
const routeDomains = (stored.route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
domains: [...domains],
|
||||
targetIps: [...targetIps],
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: matching logic
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if a route matches a profile. A profile matches if ANY condition is true:
|
||||
* 1. Profile's routeRefs contains the route's name or stored route id
|
||||
* 2. Profile's domains overlaps with route.match.domains (wildcard matching)
|
||||
* 3. Profile's targets overlaps with route.action.targets (host + port match)
|
||||
*/
|
||||
private routeMatchesProfile(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
): boolean {
|
||||
// 1. Route reference match
|
||||
if (profile.routeRefs?.length) {
|
||||
if (routeId && profile.routeRefs.includes(routeId)) return true;
|
||||
if (route.name && profile.routeRefs.includes(route.name)) return true;
|
||||
}
|
||||
|
||||
// 2. Domain match
|
||||
if (profile.domains?.length) {
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
for (const profileDomain of profile.domains) {
|
||||
for (const routeDomain of routeDomains) {
|
||||
if (this.domainMatchesPattern(routeDomain, profileDomain)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Target match (host + port)
|
||||
if (profile.targets?.length) {
|
||||
const routeTargets = (route.action as any)?.targets;
|
||||
if (Array.isArray(routeTargets)) {
|
||||
for (const profileTarget of profile.targets) {
|
||||
for (const routeTarget of routeTargets) {
|
||||
const routeHost = routeTarget.host;
|
||||
const routePort = routeTarget.port;
|
||||
if (routeHost === profileTarget.host && routePort === profileTarget.port) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain matches a pattern.
|
||||
* - '*.example.com' matches 'sub.example.com', 'a.b.example.com'
|
||||
* - 'example.com' matches only 'example.com'
|
||||
*/
|
||||
private domainMatchesPattern(domain: string, pattern: string): boolean {
|
||||
if (pattern === domain) return true;
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.slice(1); // '.example.com'
|
||||
return domain.endsWith(suffix) && domain.length > suffix.length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadProfiles(): Promise<void> {
|
||||
const docs = await TargetProfileDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.profiles.set(doc.id, {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
domains: doc.domains,
|
||||
targets: doc.targets,
|
||||
routeRefs: doc.routeRefs,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.profiles.size > 0) {
|
||||
logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistProfile(profile: ITargetProfile): Promise<void> {
|
||||
const existingDoc = await TargetProfileDoc.findById(profile.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = profile.name;
|
||||
existingDoc.description = profile.description;
|
||||
existingDoc.domains = profile.domains;
|
||||
existingDoc.targets = profile.targets;
|
||||
existingDoc.routeRefs = profile.routeRefs;
|
||||
existingDoc.updatedAt = profile.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new TargetProfileDoc();
|
||||
doc.id = profile.id;
|
||||
doc.name = profile.name;
|
||||
doc.description = profile.description;
|
||||
doc.domains = profile.domains;
|
||||
doc.targets = profile.targets;
|
||||
doc.routeRefs = profile.routeRefs;
|
||||
doc.createdAt = profile.createdAt;
|
||||
doc.updatedAt = profile.updatedAt;
|
||||
doc.createdBy = profile.createdBy;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export * from './validator.js';
|
||||
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
export { DbSeeder } from './classes.db-seeder.js';
|
||||
export { DbSeeder } from './classes.db-seeder.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
@@ -5,7 +5,7 @@ import type { IRouteSecurity } from '../../../ts_interfaces/data/route-managemen
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<SecurityProfileDoc, SecurityProfileDoc> {
|
||||
export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourceProfileDoc, SourceProfileDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
@@ -35,15 +35,15 @@ export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<Securit
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SecurityProfileDoc | null> {
|
||||
return await SecurityProfileDoc.getInstance({ id });
|
||||
public static async findById(id: string): Promise<SourceProfileDoc | null> {
|
||||
return await SourceProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<SecurityProfileDoc | null> {
|
||||
return await SecurityProfileDoc.getInstance({ name });
|
||||
public static async findByName(name: string): Promise<SourceProfileDoc | null> {
|
||||
return await SourceProfileDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SecurityProfileDoc[]> {
|
||||
return await SecurityProfileDoc.getInstances({});
|
||||
public static async findAll(): Promise<SourceProfileDoc[]> {
|
||||
return await SourceProfileDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@@ -11,7 +12,7 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public route!: plugins.smartproxy.IRouteConfig;
|
||||
public route!: IDcRouterRouteConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
52
ts/db/documents/classes.target-profile.doc.ts
Normal file
52
ts/db/documents/classes.target-profile.doc.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ITargetProfileTarget } from '../../../ts_interfaces/data/target-profile.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetProfileDoc, TargetProfileDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domains?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public targets?: ITargetProfileTarget[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public routeRefs?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<TargetProfileDoc | null> {
|
||||
return await TargetProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<TargetProfileDoc | null> {
|
||||
return await TargetProfileDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<TargetProfileDoc[]> {
|
||||
return await TargetProfileDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public serverDefinedClientTags?: string[];
|
||||
public targetProfileIds?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@@ -6,7 +6,8 @@ export * from './classes.cached.ip.reputation.js';
|
||||
export * from './classes.stored-route.doc.js';
|
||||
export * from './classes.route-override.doc.js';
|
||||
export * from './classes.api-token.doc.js';
|
||||
export * from './classes.security-profile.doc.js';
|
||||
export * from './classes.source-profile.doc.js';
|
||||
export * from './classes.target-profile.doc.js';
|
||||
export * from './classes.network-target.doc.js';
|
||||
|
||||
// VPN document classes
|
||||
|
||||
@@ -591,6 +591,10 @@ export class MetricsManager {
|
||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||
const requestsTotal = proxyMetrics.requests.total();
|
||||
|
||||
// Get frontend/backend protocol distribution
|
||||
const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null;
|
||||
const backendProtocols = proxyMetrics.connections.backendProtocols() ?? null;
|
||||
|
||||
// Collect backend protocol data
|
||||
const backendMetrics = proxyMetrics.backends.byBackend();
|
||||
const protocolCache = proxyMetrics.backends.detectedProtocols();
|
||||
@@ -705,6 +709,8 @@ export class MetricsManager {
|
||||
requestsPerSecond,
|
||||
requestsTotal,
|
||||
backends,
|
||||
frontendProtocols,
|
||||
backendProtocols,
|
||||
};
|
||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ export class OpsServer {
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||
private vpnHandler!: handlers.VpnHandler;
|
||||
private securityProfileHandler!: handlers.SecurityProfileHandler;
|
||||
private sourceProfileHandler!: handlers.SourceProfileHandler;
|
||||
private targetProfileHandler!: handlers.TargetProfileHandler;
|
||||
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
@@ -90,7 +91,8 @@ export class OpsServer {
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||
this.vpnHandler = new handlers.VpnHandler(this);
|
||||
this.securityProfileHandler = new handlers.SecurityProfileHandler(this);
|
||||
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
||||
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
||||
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
|
||||
@@ -43,7 +43,7 @@ export class CertificateHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
'reprovisionCertificateDomain',
|
||||
async (dataArg) => {
|
||||
return this.reprovisionCertificateDomain(dataArg.domain);
|
||||
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -318,7 +318,7 @@ export class CertificateHandler {
|
||||
/**
|
||||
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
||||
*/
|
||||
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||
private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const smartProxy = dcRouter.smartProxy;
|
||||
|
||||
@@ -337,8 +337,8 @@ export class CertificateHandler {
|
||||
// Try to provision via SmartAcme directly
|
||||
if (dcRouter.smartAcme) {
|
||||
try {
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: forceRenew ?? false });
|
||||
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ export * from './remoteingress.handler.js';
|
||||
export * from './route-management.handler.js';
|
||||
export * from './api-token.handler.js';
|
||||
export * from './vpn.handler.js';
|
||||
export * from './security-profile.handler.js';
|
||||
export * from './source-profile.handler.js';
|
||||
export * from './target-profile.handler.js';
|
||||
export * from './network-target.handler.js';
|
||||
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class SecurityProfileHandler {
|
||||
export class SourceProfileHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
@@ -40,12 +40,12 @@ export class SecurityProfileHandler {
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all security profiles
|
||||
// Get all source profiles
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||
'getSecurityProfiles',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfiles>(
|
||||
'getSourceProfiles',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:read');
|
||||
await this.requireAuth(dataArg, 'source-profiles:read');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
if (!resolver) {
|
||||
return { profiles: [] };
|
||||
@@ -55,12 +55,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Get a single security profile
|
||||
// Get a single source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfile>(
|
||||
'getSecurityProfile',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfile>(
|
||||
'getSourceProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:read');
|
||||
await this.requireAuth(dataArg, 'source-profiles:read');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
if (!resolver) {
|
||||
return { profile: null };
|
||||
@@ -70,12 +70,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Create a security profile
|
||||
// Create a source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||
'createSecurityProfile',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSourceProfile>(
|
||||
'createSourceProfile',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'profiles:write');
|
||||
const userId = await this.requireAuth(dataArg, 'source-profiles:write');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
if (!resolver) {
|
||||
return { success: false, message: 'Reference resolver not initialized' };
|
||||
@@ -92,12 +92,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Update a security profile
|
||||
// Update a source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityProfile>(
|
||||
'updateSecurityProfile',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSourceProfile>(
|
||||
'updateSourceProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:write');
|
||||
await this.requireAuth(dataArg, 'source-profiles:write');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!resolver || !manager) {
|
||||
@@ -121,12 +121,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Delete a security profile
|
||||
// Delete a source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityProfile>(
|
||||
'deleteSecurityProfile',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSourceProfile>(
|
||||
'deleteSourceProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:write');
|
||||
await this.requireAuth(dataArg, 'source-profiles:write');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!resolver || !manager) {
|
||||
@@ -149,12 +149,12 @@ export class SecurityProfileHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Get routes using a security profile
|
||||
// Get routes using a source profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||
'getSecurityProfileUsage',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfileUsage>(
|
||||
'getSourceProfileUsage',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'profiles:read');
|
||||
await this.requireAuth(dataArg, 'source-profiles:read');
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!resolver || !manager) {
|
||||
@@ -311,6 +311,8 @@ export class StatsHandler {
|
||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||
requestsTotal: stats.requestsTotal || 0,
|
||||
backends: stats.backends || [],
|
||||
frontendProtocols: stats.frontendProtocols || null,
|
||||
backendProtocols: stats.backendProtocols || null,
|
||||
};
|
||||
})()
|
||||
);
|
||||
|
||||
155
ts/opsserver/handlers/target-profile.handler.ts
Normal file
155
ts/opsserver/handlers/target-profile.handler.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class TargetProfileHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all target profiles
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfiles>(
|
||||
'getTargetProfiles',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { profiles: [] };
|
||||
}
|
||||
return { profiles: manager.listProfiles() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get a single target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfile>(
|
||||
'getTargetProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { profile: null };
|
||||
}
|
||||
return { profile: manager.getProfile(dataArg.id) || null };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create a target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateTargetProfile>(
|
||||
'createTargetProfile',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'target-profiles:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Target profile manager not initialized' };
|
||||
}
|
||||
const id = await manager.createProfile({
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, id };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update a target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateTargetProfile>(
|
||||
'updateTargetProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Not initialized' };
|
||||
}
|
||||
await manager.updateProfile(dataArg.id, {
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
});
|
||||
// Re-apply routes to update VPN access
|
||||
this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete a target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteTargetProfile>(
|
||||
'deleteTargetProfile',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Not initialized' };
|
||||
}
|
||||
const result = await manager.deleteProfile(dataArg.id, dataArg.force);
|
||||
if (result.success) {
|
||||
// Re-apply routes to update VPN access
|
||||
this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get VPN clients using a target profile
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfileUsage>(
|
||||
'getTargetProfileUsage',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'target-profiles:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
|
||||
if (!manager) {
|
||||
return { clients: [] };
|
||||
}
|
||||
return { clients: await manager.getProfileUsage(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export class VpnHandler {
|
||||
const clients = manager.listClients().map((c) => ({
|
||||
clientId: c.clientId,
|
||||
enabled: c.enabled,
|
||||
serverDefinedClientTags: c.serverDefinedClientTags,
|
||||
targetProfileIds: c.targetProfileIds,
|
||||
description: c.description,
|
||||
assignedIp: c.assignedIp,
|
||||
createdAt: c.createdAt,
|
||||
@@ -120,7 +120,7 @@ export class VpnHandler {
|
||||
try {
|
||||
const bundle = await manager.createClient({
|
||||
clientId: dataArg.clientId,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
description: dataArg.description,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
@@ -142,7 +142,7 @@ export class VpnHandler {
|
||||
client: {
|
||||
clientId: bundle.entry.clientId,
|
||||
enabled: bundle.entry.enabled ?? true,
|
||||
serverDefinedClientTags: bundle.entry.serverDefinedClientTags,
|
||||
targetProfileIds: persistedClient?.targetProfileIds,
|
||||
description: bundle.entry.description,
|
||||
assignedIp: bundle.entry.assignedIp,
|
||||
createdAt: Date.now(),
|
||||
@@ -179,7 +179,7 @@ export class VpnHandler {
|
||||
try {
|
||||
await manager.updateClient(dataArg.clientId, {
|
||||
description: dataArg.description,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface IVpnManagerConfig {
|
||||
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
|
||||
initialClients?: Array<{
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
}>;
|
||||
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
||||
@@ -26,10 +26,10 @@ export interface IVpnManagerConfig {
|
||||
allowList?: string[];
|
||||
blockList?: string[];
|
||||
};
|
||||
/** Compute per-client AllowedIPs based on the client's server-defined tags.
|
||||
/** Compute per-client AllowedIPs based on the client's target profile IDs.
|
||||
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||
* When not set, defaults to [subnet]. */
|
||||
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
|
||||
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
|
||||
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
|
||||
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
|
||||
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
@@ -90,7 +90,6 @@ export class VpnManager {
|
||||
publicKey: client.noisePublicKey,
|
||||
wgPublicKey: client.wgPublicKey,
|
||||
enabled: client.enabled,
|
||||
serverDefinedClientTags: client.serverDefinedClientTags,
|
||||
description: client.description,
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
@@ -163,7 +162,7 @@ export class VpnManager {
|
||||
if (!this.clients.has(initial.clientId)) {
|
||||
const bundle = await this.createClient({
|
||||
clientId: initial.clientId,
|
||||
serverDefinedClientTags: initial.serverDefinedClientTags,
|
||||
targetProfileIds: initial.targetProfileIds,
|
||||
description: initial.description,
|
||||
});
|
||||
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
|
||||
@@ -197,7 +196,7 @@ export class VpnManager {
|
||||
*/
|
||||
public async createClient(opts: {
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
@@ -214,13 +213,12 @@ export class VpnManager {
|
||||
|
||||
const bundle = await this.vpnServer.createClient({
|
||||
clientId: opts.clientId,
|
||||
serverDefinedClientTags: opts.serverDefinedClientTags,
|
||||
description: opts.description,
|
||||
});
|
||||
|
||||
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
@@ -231,7 +229,7 @@ export class VpnManager {
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = bundle.entry.clientId;
|
||||
doc.enabled = bundle.entry.enabled ?? true;
|
||||
doc.serverDefinedClientTags = bundle.entry.serverDefinedClientTags;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = bundle.entry.description;
|
||||
doc.assignedIp = bundle.entry.assignedIp;
|
||||
doc.noisePublicKey = bundle.entry.publicKey;
|
||||
@@ -332,11 +330,11 @@ export class VpnManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a client's metadata (description, tags) without rotating keys.
|
||||
* Update a client's metadata (description, target profiles) without rotating keys.
|
||||
*/
|
||||
public async updateClient(clientId: string, update: {
|
||||
description?: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
@@ -349,7 +347,7 @@ export class VpnManager {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) throw new Error(`Client not found: ${clientId}`);
|
||||
if (update.description !== undefined) client.description = update.description;
|
||||
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
|
||||
if (update.targetProfileIds !== undefined) client.targetProfileIds = update.targetProfileIds;
|
||||
if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
|
||||
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
|
||||
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
|
||||
@@ -409,10 +407,10 @@ export class VpnManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs) {
|
||||
const clientTags = persisted?.serverDefinedClientTags || [];
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(clientTags);
|
||||
const profileIds = persisted?.targetProfileIds || [];
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
|
||||
config = config.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
@@ -423,22 +421,6 @@ export class VpnManager {
|
||||
return config;
|
||||
}
|
||||
|
||||
// ── Tag-based access control ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
|
||||
*/
|
||||
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
|
||||
const ips: string[] = [];
|
||||
for (const client of this.clients.values()) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
|
||||
ips.push(client.assignedIp);
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
// ── Status and telemetry ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -548,12 +530,6 @@ export class VpnManager {
|
||||
private async loadPersistedClients(): Promise<void> {
|
||||
const docs = await VpnClientDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
// Migrate legacy `tags` → `serverDefinedClientTags`
|
||||
if (!doc.serverDefinedClientTags && (doc as any).tags) {
|
||||
doc.serverDefinedClientTags = (doc as any).tags;
|
||||
(doc as any).tags = undefined;
|
||||
await doc.save();
|
||||
}
|
||||
this.clients.set(doc.clientId, doc);
|
||||
}
|
||||
if (this.clients.size > 0) {
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from './auth.js';
|
||||
export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './target-profile.js';
|
||||
export * from './vpn.js';
|
||||
@@ -51,26 +51,14 @@ export interface IRouteRemoteIngress {
|
||||
edgeFilter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level VPN access configuration.
|
||||
* When attached to a route, controls VPN client access.
|
||||
*/
|
||||
export interface IRouteVpn {
|
||||
/** Enable VPN client access for this route */
|
||||
enabled: boolean;
|
||||
/** When true (default), ONLY VPN clients can access this route (replaces ipAllowList).
|
||||
* When false, VPN client IPs are added alongside the existing allowlist. */
|
||||
mandatory?: boolean;
|
||||
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
|
||||
allowedServerDefinedClientTags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended route config used within dcrouter.
|
||||
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig.
|
||||
* Adds optional `remoteIngress` and `vpnOnly` properties to SmartProxy's IRouteConfig.
|
||||
* SmartProxy ignores unknown properties at runtime.
|
||||
*/
|
||||
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||
remoteIngress?: IRouteRemoteIngress;
|
||||
vpn?: IRouteVpn;
|
||||
/** When true, only VPN clients whose TargetProfile matches this route get access.
|
||||
* Matching is determined by domain overlap, target overlap, or direct routeRef. */
|
||||
vpnOnly?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
import type { IDcRouterRouteConfig } from './remoteingress.js';
|
||||
|
||||
// Derive IRouteSecurity from IRouteConfig since it's not directly exported
|
||||
export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||
@@ -11,18 +12,22 @@ export type TApiTokenScope =
|
||||
| 'routes:read' | 'routes:write'
|
||||
| 'config:read'
|
||||
| 'tokens:read' | 'tokens:manage'
|
||||
| 'profiles:read' | 'profiles:write'
|
||||
| 'source-profiles:read' | 'source-profiles:write'
|
||||
| 'target-profiles:read' | 'target-profiles:write'
|
||||
| 'targets:read' | 'targets:write';
|
||||
|
||||
// ============================================================================
|
||||
// Security Profile Types
|
||||
// Source Profile Types (source-side: who can access)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A reusable, named security profile that can be referenced by routes.
|
||||
* A reusable, named source profile that can be referenced by routes.
|
||||
* Stores the full IRouteSecurity shape from SmartProxy.
|
||||
*
|
||||
* SourceProfile = source-side (who can access: ipAllowList, rateLimit, auth)
|
||||
* TargetProfile = target-side (what can be accessed: domains, IP:port targets, route refs)
|
||||
*/
|
||||
export interface ISecurityProfile {
|
||||
export interface ISourceProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -61,12 +66,12 @@ export interface INetworkTarget {
|
||||
* Metadata on a stored route tracking where its resolved values came from.
|
||||
*/
|
||||
export interface IRouteMetadata {
|
||||
/** ID of the SecurityProfileDoc used to resolve this route's security. */
|
||||
securityProfileRef?: string;
|
||||
/** ID of the SourceProfileDoc used to resolve this route's security. */
|
||||
sourceProfileRef?: string;
|
||||
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
|
||||
networkTargetRef?: string;
|
||||
/** Snapshot of the profile name at resolution time, for display. */
|
||||
securityProfileName?: string;
|
||||
sourceProfileName?: string;
|
||||
/** Snapshot of the target name at resolution time, for display. */
|
||||
networkTargetName?: string;
|
||||
/** Timestamp of last reference resolution. */
|
||||
@@ -77,7 +82,7 @@ export interface IRouteMetadata {
|
||||
* A merged route combining hardcoded and programmatic sources.
|
||||
*/
|
||||
export interface IMergedRoute {
|
||||
route: IRouteConfig;
|
||||
route: IDcRouterRouteConfig;
|
||||
source: 'hardcoded' | 'programmatic';
|
||||
enabled: boolean;
|
||||
overridden: boolean;
|
||||
@@ -118,7 +123,7 @@ export interface IApiTokenInfo {
|
||||
*/
|
||||
export interface IStoredRoute {
|
||||
id: string;
|
||||
route: IRouteConfig;
|
||||
route: IDcRouterRouteConfig;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
|
||||
@@ -166,6 +166,21 @@ export interface INetworkMetrics {
|
||||
requestsPerSecond?: number;
|
||||
requestsTotal?: number;
|
||||
backends?: IBackendInfo[];
|
||||
frontendProtocols?: IProtocolDistribution | null;
|
||||
backendProtocols?: IProtocolDistribution | null;
|
||||
}
|
||||
|
||||
export interface IProtocolDistribution {
|
||||
h1Active: number;
|
||||
h1Total: number;
|
||||
h2Active: number;
|
||||
h2Total: number;
|
||||
h3Active: number;
|
||||
h3Total: number;
|
||||
wsActive: number;
|
||||
wsTotal: number;
|
||||
otherActive: number;
|
||||
otherTotal: number;
|
||||
}
|
||||
|
||||
export interface IConnectionDetails {
|
||||
|
||||
29
ts_interfaces/data/target-profile.ts
Normal file
29
ts_interfaces/data/target-profile.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* A specific IP:port target within a TargetProfile.
|
||||
*/
|
||||
export interface ITargetProfileTarget {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable, named target profile that defines what resources a VPN client can reach.
|
||||
* Assigned to VPN clients via targetProfileIds.
|
||||
*
|
||||
* SourceProfile = source-side (who can access: ipAllowList, rateLimit, auth)
|
||||
* TargetProfile = target-side (what can be accessed: domains, IP:port targets, route refs)
|
||||
*/
|
||||
export interface ITargetProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Domain patterns this profile grants access to (supports wildcards: '*.example.com') */
|
||||
domains?: string[];
|
||||
/** Specific IP:port targets this profile grants access to */
|
||||
targets?: ITargetProfileTarget[];
|
||||
/** Route references by stored route ID or route name */
|
||||
routeRefs?: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
export interface IVpnClient {
|
||||
clientId: string;
|
||||
enabled: boolean;
|
||||
serverDefinedClientTags?: string[];
|
||||
/** IDs of TargetProfiles assigned to this client */
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
assignedIp?: string;
|
||||
createdAt: number;
|
||||
|
||||
@@ -80,6 +80,8 @@ interface IIdentity {
|
||||
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
||||
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||
| `IRadiusStats` | Running, uptime, auth requests/accepts/rejects, sessions, data transfer |
|
||||
| `IVpnStats` | Running, subnet, registered/connected clients, WireGuard port |
|
||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||
|
||||
#### Route Management Interfaces
|
||||
@@ -135,7 +137,8 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
||||
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
||||
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
||||
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request |
|
||||
| `IReq_GetNetworkStats` | `getNetworkStats` | Network throughput and connection analytics |
|
||||
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request (server, email, DNS, security, network, RADIUS, VPN) |
|
||||
|
||||
#### ⚙️ Configuration
|
||||
| Interface | Method | Description |
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
domain: string;
|
||||
forceRenew?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
|
||||
@@ -10,5 +10,6 @@ export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './api-tokens.js';
|
||||
export * from './vpn.js';
|
||||
export * from './security-profiles.js';
|
||||
export * from './source-profiles.js';
|
||||
export * from './target-profiles.js';
|
||||
export * from './network-targets.js';
|
||||
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Endpoints
|
||||
@@ -36,7 +37,7 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
route: IRouteConfig;
|
||||
route: IDcRouterRouteConfig;
|
||||
enabled?: boolean;
|
||||
metadata?: IRouteMetadata;
|
||||
};
|
||||
@@ -59,7 +60,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
route?: Partial<IRouteConfig>;
|
||||
route?: Partial<IDcRouterRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
};
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { ISecurityProfile, IRouteSecurity } from '../data/route-management.js';
|
||||
import type { ISourceProfile, IRouteSecurity } from '../data/route-management.js';
|
||||
|
||||
// ============================================================================
|
||||
// Security Profile Endpoints
|
||||
// Source Profile Endpoints (source-side: who can access)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all security profiles.
|
||||
* Get all source profiles.
|
||||
*/
|
||||
export interface IReq_GetSecurityProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetSourceProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityProfiles
|
||||
IReq_GetSourceProfiles
|
||||
> {
|
||||
method: 'getSecurityProfiles';
|
||||
method: 'getSourceProfiles';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
profiles: ISecurityProfile[];
|
||||
profiles: ISourceProfile[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single security profile by ID.
|
||||
* Get a single source profile by ID.
|
||||
*/
|
||||
export interface IReq_GetSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityProfile
|
||||
IReq_GetSourceProfile
|
||||
> {
|
||||
method: 'getSecurityProfile';
|
||||
method: 'getSourceProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
profile: ISecurityProfile | null;
|
||||
profile: ISourceProfile | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new security profile.
|
||||
* Create a new source profile.
|
||||
*/
|
||||
export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_CreateSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateSecurityProfile
|
||||
IReq_CreateSourceProfile
|
||||
> {
|
||||
method: 'createSecurityProfile';
|
||||
method: 'createSourceProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
@@ -65,13 +65,13 @@ export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfac
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a security profile.
|
||||
* Update a source profile.
|
||||
*/
|
||||
export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_UpdateSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateSecurityProfile
|
||||
IReq_UpdateSourceProfile
|
||||
> {
|
||||
method: 'updateSecurityProfile';
|
||||
method: 'updateSourceProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
@@ -89,13 +89,13 @@ export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfac
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a security profile.
|
||||
* Delete a source profile.
|
||||
*/
|
||||
export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_DeleteSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteSecurityProfile
|
||||
IReq_DeleteSourceProfile
|
||||
> {
|
||||
method: 'deleteSecurityProfile';
|
||||
method: 'deleteSourceProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
@@ -109,13 +109,13 @@ export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfac
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which routes reference a security profile.
|
||||
* Get which routes reference a source profile.
|
||||
*/
|
||||
export interface IReq_GetSecurityProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetSourceProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityProfileUsage
|
||||
IReq_GetSourceProfileUsage
|
||||
> {
|
||||
method: 'getSecurityProfileUsage';
|
||||
method: 'getSourceProfileUsage';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
128
ts_interfaces/requests/target-profiles.ts
Normal file
128
ts_interfaces/requests/target-profiles.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { ITargetProfile, ITargetProfileTarget } from '../data/target-profile.js';
|
||||
|
||||
// ============================================================================
|
||||
// Target Profile Endpoints (target-side: what can be accessed)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all target profiles.
|
||||
*/
|
||||
export interface IReq_GetTargetProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetTargetProfiles
|
||||
> {
|
||||
method: 'getTargetProfiles';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
profiles: ITargetProfile[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single target profile by ID.
|
||||
*/
|
||||
export interface IReq_GetTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetTargetProfile
|
||||
> {
|
||||
method: 'getTargetProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
profile: ITargetProfile | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new target profile.
|
||||
*/
|
||||
export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateTargetProfile
|
||||
> {
|
||||
method: 'createTargetProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: ITargetProfileTarget[];
|
||||
routeRefs?: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a target profile.
|
||||
*/
|
||||
export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateTargetProfile
|
||||
> {
|
||||
method: 'updateTargetProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: ITargetProfileTarget[];
|
||||
routeRefs?: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a target profile.
|
||||
*/
|
||||
export interface IReq_DeleteTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteTargetProfile
|
||||
> {
|
||||
method: 'deleteTargetProfile';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
force?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which VPN clients reference a target profile.
|
||||
*/
|
||||
export interface IReq_GetTargetProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetTargetProfileUsage
|
||||
> {
|
||||
method: 'getTargetProfileUsage';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
clients: Array<{ clientId: string; description?: string }>;
|
||||
};
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
@@ -81,7 +81,7 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
|
||||
identity: authInterfaces.IIdentity;
|
||||
clientId: string;
|
||||
description?: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '12.7.0',
|
||||
version: '13.0.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface INetworkState {
|
||||
requestsPerSecond: number;
|
||||
requestsTotal: number;
|
||||
backends: interfaces.data.IBackendInfo[];
|
||||
frontendProtocols: interfaces.data.IProtocolDistribution | null;
|
||||
backendProtocols: interfaces.data.IProtocolDistribution | null;
|
||||
lastUpdated: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -114,7 +116,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
||||
// Determine initial view from URL path
|
||||
const getInitialView = (): string => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'securityprofiles', 'networktargets'];
|
||||
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'sourceprofiles', 'networktargets', 'targetprofiles'];
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
return validViews.includes(view) ? view : 'overview';
|
||||
@@ -154,6 +156,8 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [],
|
||||
frontendProtocols: null,
|
||||
backendProtocols: null,
|
||||
lastUpdated: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@@ -455,12 +459,19 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
||||
}
|
||||
|
||||
// If switching to security profiles or network targets views, fetch profiles/targets data
|
||||
if ((viewName === 'securityprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
|
||||
if ((viewName === 'sourceprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
|
||||
setTimeout(() => {
|
||||
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to target profiles view, fetch target profiles data
|
||||
if (viewName === 'targetprofiles' && currentState.activeView !== viewName) {
|
||||
setTimeout(() => {
|
||||
targetProfilesStatePart.dispatchAction(fetchTargetProfilesAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
activeView: viewName,
|
||||
@@ -523,6 +534,8 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
||||
requestsTotal: networkStatsResponse.requestsTotal || 0,
|
||||
backends: networkStatsResponse.backends || [],
|
||||
frontendProtocols: networkStatsResponse.frontendProtocols || null,
|
||||
backendProtocols: networkStatsResponse.backendProtocols || null,
|
||||
lastUpdated: Date.now(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@@ -605,8 +618,8 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
|
||||
}
|
||||
});
|
||||
|
||||
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
|
||||
async (statePartArg, domain, actionContext): Promise<ICertificateState> => {
|
||||
export const reprovisionCertificateAction = certificateStatePart.createAction<{ domain: string; forceRenew?: boolean }>(
|
||||
async (statePartArg, dataArg, actionContext): Promise<ICertificateState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
|
||||
@@ -617,7 +630,8 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity!,
|
||||
domain,
|
||||
domain: dataArg.domain,
|
||||
forceRenew: dataArg.forceRenew,
|
||||
});
|
||||
|
||||
// Re-fetch overview after reprovisioning
|
||||
@@ -999,7 +1013,7 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
|
||||
|
||||
export const createVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
description?: string;
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
@@ -1021,7 +1035,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
clientId: dataArg.clientId,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
description: dataArg.description,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
@@ -1098,7 +1112,7 @@ export const toggleVpnClientAction = vpnStatePart.createAction<{
|
||||
export const updateVpnClientAction = vpnStatePart.createAction<{
|
||||
clientId: string;
|
||||
description?: string;
|
||||
serverDefinedClientTags?: string[];
|
||||
targetProfileIds?: string[];
|
||||
forceDestinationSmartproxy?: boolean;
|
||||
destinationAllowList?: string[];
|
||||
destinationBlockList?: string[];
|
||||
@@ -1120,7 +1134,7 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
|
||||
identity: context.identity!,
|
||||
clientId: dataArg.clientId,
|
||||
description: dataArg.description,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
targetProfileIds: dataArg.targetProfileIds,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
@@ -1151,11 +1165,167 @@ export const clearNewClientConfigAction = vpnStatePart.createAction(
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Security Profiles & Network Targets State
|
||||
// Target Profiles State
|
||||
// ============================================================================
|
||||
|
||||
export interface ITargetProfilesState {
|
||||
profiles: interfaces.data.ITargetProfile[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export const targetProfilesStatePart = await appState.getStatePart<ITargetProfilesState>(
|
||||
'targetProfiles',
|
||||
{
|
||||
profiles: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
'soft'
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Target Profiles Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchTargetProfilesAction = targetProfilesStatePart.createAction(
|
||||
async (statePartArg): Promise<ITargetProfilesState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetTargetProfiles
|
||||
>('/typedrequest', 'getTargetProfiles');
|
||||
|
||||
const response = await request.fire({ identity: context.identity });
|
||||
|
||||
return {
|
||||
profiles: response.profiles,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch target profiles',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
name: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: Array<{ host: string; port: number }>;
|
||||
routeRefs?: string[];
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateTargetProfile
|
||||
>('/typedrequest', 'createTargetProfile');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: response.message || 'Failed to create target profile',
|
||||
};
|
||||
}
|
||||
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to create target profile',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: Array<{ host: string; port: number }>;
|
||||
routeRefs?: string[];
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateTargetProfile
|
||||
>('/typedrequest', 'updateTargetProfile');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
id: dataArg.id,
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
domains: dataArg.domains,
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: response.message || 'Failed to update target profile',
|
||||
};
|
||||
}
|
||||
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to update target profile',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteTargetProfileAction = targetProfilesStatePart.createAction<{
|
||||
id: string;
|
||||
force?: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteTargetProfile
|
||||
>('/typedrequest', 'deleteTargetProfile');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
id: dataArg.id,
|
||||
force: dataArg.force,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: response.message || 'Failed to delete target profile',
|
||||
};
|
||||
}
|
||||
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete target profile',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Source Profiles & Network Targets State
|
||||
// ============================================================================
|
||||
|
||||
export interface IProfilesTargetsState {
|
||||
profiles: interfaces.data.ISecurityProfile[];
|
||||
profiles: interfaces.data.ISourceProfile[];
|
||||
targets: interfaces.data.INetworkTarget[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -1175,7 +1345,7 @@ export const profilesTargetsStatePart = await appState.getStatePart<IProfilesTar
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Security Profiles & Network Targets Actions
|
||||
// Source Profiles & Network Targets Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createAction(
|
||||
@@ -1186,8 +1356,8 @@ export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createActi
|
||||
|
||||
try {
|
||||
const profilesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSecurityProfiles
|
||||
>('/typedrequest', 'getSecurityProfiles');
|
||||
interfaces.requests.IReq_GetSourceProfiles
|
||||
>('/typedrequest', 'getSourceProfiles');
|
||||
|
||||
const targetsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetNetworkTargets
|
||||
@@ -1224,8 +1394,8 @@ export const createProfileAction = profilesTargetsStatePart.createAction<{
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateSecurityProfile
|
||||
>('/typedrequest', 'createSecurityProfile');
|
||||
interfaces.requests.IReq_CreateSourceProfile
|
||||
>('/typedrequest', 'createSourceProfile');
|
||||
await request.fire({
|
||||
identity: context.identity!,
|
||||
name: dataArg.name,
|
||||
@@ -1252,8 +1422,8 @@ export const updateProfileAction = profilesTargetsStatePart.createAction<{
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateSecurityProfile
|
||||
>('/typedrequest', 'updateSecurityProfile');
|
||||
interfaces.requests.IReq_UpdateSourceProfile
|
||||
>('/typedrequest', 'updateSourceProfile');
|
||||
await request.fire({
|
||||
identity: context.identity!,
|
||||
id: dataArg.id,
|
||||
@@ -1278,8 +1448,8 @@ export const deleteProfileAction = profilesTargetsStatePart.createAction<{
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteSecurityProfile
|
||||
>('/typedrequest', 'deleteSecurityProfile');
|
||||
interfaces.requests.IReq_DeleteSourceProfile
|
||||
>('/typedrequest', 'deleteSourceProfile');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
id: dataArg.id,
|
||||
@@ -1844,6 +2014,8 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
requestsPerSecond: network.requestsPerSecond || 0,
|
||||
requestsTotal: network.requestsTotal || 0,
|
||||
backends: network.backends || [],
|
||||
frontendProtocols: network.frontendProtocols || null,
|
||||
backendProtocols: network.backendProtocols || null,
|
||||
lastUpdated: Date.now(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@@ -1865,6 +2037,8 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
requestsPerSecond: network.requestsPerSecond || 0,
|
||||
requestsTotal: network.requestsTotal || 0,
|
||||
backends: network.backends || [],
|
||||
frontendProtocols: network.frontendProtocols || null,
|
||||
backendProtocols: network.backendProtocols || null,
|
||||
lastUpdated: Date.now(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from './ops-view-security.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './ops-view-vpn.js';
|
||||
export * from './ops-view-securityprofiles.js';
|
||||
export * from './ops-view-sourceprofiles.js';
|
||||
export * from './ops-view-networktargets.js';
|
||||
export * from './ops-view-targetprofiles.js';
|
||||
export * from './shared/index.js';
|
||||
@@ -24,8 +24,9 @@ import { OpsViewSecurity } from './ops-view-security.js';
|
||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||
import { OpsViewSecurityProfiles } from './ops-view-securityprofiles.js';
|
||||
import { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js';
|
||||
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
|
||||
import { OpsViewTargetProfiles } from './ops-view-targetprofiles.js';
|
||||
|
||||
@customElement('ops-dashboard')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@@ -81,15 +82,20 @@ export class OpsDashboard extends DeesElement {
|
||||
element: OpsViewRoutes,
|
||||
},
|
||||
{
|
||||
name: 'SecurityProfiles',
|
||||
name: 'SourceProfiles',
|
||||
iconName: 'lucide:shieldCheck',
|
||||
element: OpsViewSecurityProfiles,
|
||||
element: OpsViewSourceProfiles,
|
||||
},
|
||||
{
|
||||
name: 'NetworkTargets',
|
||||
iconName: 'lucide:server',
|
||||
element: OpsViewNetworkTargets,
|
||||
},
|
||||
{
|
||||
name: 'TargetProfiles',
|
||||
iconName: 'lucide:target',
|
||||
element: OpsViewTargetProfiles,
|
||||
},
|
||||
{
|
||||
name: 'ApiTokens',
|
||||
iconName: 'lucide:key',
|
||||
|
||||
@@ -312,14 +312,16 @@ export class OpsViewCertificates extends DeesElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const doReprovision = async () => {
|
||||
const doReprovision = async (forceRenew = false) => {
|
||||
await appstate.certificateStatePart.dispatchAction(
|
||||
appstate.reprovisionCertificateAction,
|
||||
cert.domain,
|
||||
{ domain: cert.domain, forceRenew },
|
||||
);
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({
|
||||
message: `Reprovisioning triggered for ${cert.domain}`,
|
||||
message: forceRenew
|
||||
? `Force renewal triggered for ${cert.domain}`
|
||||
: `Reprovisioning triggered for ${cert.domain}`,
|
||||
type: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
@@ -336,7 +338,7 @@ export class OpsViewCertificates extends DeesElement {
|
||||
name: 'Force Renew',
|
||||
action: async (modalArg: any) => {
|
||||
await modalArg.destroy();
|
||||
await doReprovision();
|
||||
await doReprovision(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -274,6 +274,12 @@ export class OpsViewNetwork extends DeesElement {
|
||||
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
|
||||
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
|
||||
}
|
||||
|
||||
.protocolChartGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -305,6 +311,9 @@ export class OpsViewNetwork extends DeesElement {
|
||||
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
||||
></dees-chart-area>
|
||||
|
||||
<!-- Protocol Distribution Charts -->
|
||||
${this.renderProtocolCharts()}
|
||||
|
||||
<!-- Top IPs Section -->
|
||||
${this.renderTopIPs()}
|
||||
|
||||
@@ -527,7 +536,54 @@ export class OpsViewNetwork extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
private renderProtocolCharts(): TemplateResult {
|
||||
const fp = this.networkState.frontendProtocols;
|
||||
const bp = this.networkState.backendProtocols;
|
||||
|
||||
const protoColors: Record<string, string> = {
|
||||
'HTTP/1.1': '#1976d2',
|
||||
'HTTP/2': '#388e3c',
|
||||
'HTTP/3': '#7b1fa2',
|
||||
'WebSocket': '#f57c00',
|
||||
'Other': '#757575',
|
||||
};
|
||||
|
||||
const buildDonutData = (dist: interfaces.data.IProtocolDistribution | null) => {
|
||||
if (!dist) return [];
|
||||
const items: Array<{ name: string; value: number; color: string }> = [];
|
||||
if (dist.h1Active > 0) items.push({ name: 'HTTP/1.1', value: dist.h1Active, color: protoColors['HTTP/1.1'] });
|
||||
if (dist.h2Active > 0) items.push({ name: 'HTTP/2', value: dist.h2Active, color: protoColors['HTTP/2'] });
|
||||
if (dist.h3Active > 0) items.push({ name: 'HTTP/3', value: dist.h3Active, color: protoColors['HTTP/3'] });
|
||||
if (dist.wsActive > 0) items.push({ name: 'WebSocket', value: dist.wsActive, color: protoColors['WebSocket'] });
|
||||
if (dist.otherActive > 0) items.push({ name: 'Other', value: dist.otherActive, color: protoColors['Other'] });
|
||||
return items;
|
||||
};
|
||||
|
||||
const frontendData = buildDonutData(fp);
|
||||
const backendData = buildDonutData(bp);
|
||||
|
||||
return html`
|
||||
<div class="protocolChartGrid">
|
||||
<dees-chart-donut
|
||||
.label=${'Frontend Protocols'}
|
||||
.data=${frontendData.length > 0 ? frontendData : [{ name: 'No Traffic', value: 1, color: '#757575' }]}
|
||||
.showLegend=${true}
|
||||
.showLabels=${true}
|
||||
.innerRadiusPercent=${'55%'}
|
||||
.valueFormatter=${(val: number) => `${val} active`}
|
||||
></dees-chart-donut>
|
||||
<dees-chart-donut
|
||||
.label=${'Backend Protocols'}
|
||||
.data=${backendData.length > 0 ? backendData : [{ name: 'No Traffic', value: 1, color: '#757575' }]}
|
||||
.showLegend=${true}
|
||||
.showLabels=${true}
|
||||
.innerRadiusPercent=${'55%'}
|
||||
.valueFormatter=${(val: number) => `${val} active`}
|
||||
></dees-chart-donut>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTopIPs(): TemplateResult {
|
||||
if (this.networkState.topIPs.length === 0) {
|
||||
return html``;
|
||||
|
||||
@@ -64,13 +64,6 @@ export class OpsViewOverview extends DeesElement {
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
h2 {
|
||||
margin: 32px 0 16px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
.chartGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -123,6 +116,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
|
||||
${this.renderVpnStats()}
|
||||
|
||||
<dees-heading level="hr">Activity Charts</dees-heading>
|
||||
<div class="chartGrid">
|
||||
<dees-chart-area
|
||||
.label=${'Email Traffic (24h)'}
|
||||
@@ -330,7 +324,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<h2>Email Statistics</h2>
|
||||
<dees-heading level="hr">Email Statistics</dees-heading>
|
||||
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
@@ -379,7 +373,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<h2>DNS Statistics</h2>
|
||||
<dees-heading level="hr">DNS Statistics</dees-heading>
|
||||
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
@@ -430,7 +424,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<h2>RADIUS Statistics</h2>
|
||||
<dees-heading level="hr">RADIUS Statistics</dees-heading>
|
||||
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
@@ -470,7 +464,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<h2>VPN Statistics</h2>
|
||||
<dees-heading level="hr">VPN Statistics</dees-heading>
|
||||
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,40 @@ import {
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
// TLS dropdown options shared by create and edit dialogs
|
||||
const tlsModeOptions = [
|
||||
{ key: 'none', option: '(none — no TLS)' },
|
||||
{ key: 'passthrough', option: 'Passthrough' },
|
||||
{ key: 'terminate', option: 'Terminate' },
|
||||
{ key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt' },
|
||||
];
|
||||
const tlsCertOptions = [
|
||||
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
|
||||
{ key: 'custom', option: 'Custom certificate' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Toggle TLS form field visibility based on selected TLS mode and certificate type.
|
||||
*/
|
||||
function setupTlsVisibility(formEl: any) {
|
||||
const updateVisibility = async () => {
|
||||
const data = await formEl.collectFormData();
|
||||
const contentEl = formEl.closest('.content') || formEl.parentElement;
|
||||
if (!contentEl) return;
|
||||
const tlsModeValue = data.tlsMode;
|
||||
const modeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
|
||||
const needsCert = modeKey === 'terminate' || modeKey === 'terminate-and-reencrypt';
|
||||
const certGroup = contentEl.querySelector('.tlsCertificateGroup') as HTMLElement;
|
||||
if (certGroup) certGroup.style.display = needsCert ? 'flex' : 'none';
|
||||
const tlsCertValue = data.tlsCertificate;
|
||||
const certKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
|
||||
const customGroup = contentEl.querySelector('.tlsCustomCertGroup') as HTMLElement;
|
||||
if (customGroup) customGroup.style.display = (needsCert && certKey === 'custom') ? 'flex' : 'none';
|
||||
};
|
||||
formEl.changeSubject.subscribe(() => updateVisibility());
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
@customElement('ops-view-routes')
|
||||
export class OpsViewRoutes extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
@@ -303,7 +337,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
|
||||
${meta?.securityProfileName ? html`<p>Security Profile: <strong style="color: #a78bfa;">${meta.securityProfileName}</strong></p>` : ''}
|
||||
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
|
||||
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
|
||||
</div>
|
||||
`,
|
||||
@@ -423,7 +457,18 @@ export class OpsViewRoutes extends DeesElement {
|
||||
: '';
|
||||
const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : '';
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
// Compute current TLS state for pre-population
|
||||
const currentTls = (route.action as any).tls;
|
||||
const currentTlsMode = currentTls?.mode || 'none';
|
||||
const currentTlsCert = currentTls
|
||||
? (currentTls.certificate === 'auto' || !currentTls.certificate ? 'auto' : 'custom')
|
||||
: 'auto';
|
||||
const currentCustomKey = (typeof currentTls?.certificate === 'object') ? currentTls.certificate.key : '';
|
||||
const currentCustomCert = (typeof currentTls?.certificate === 'object') ? currentTls.certificate.cert : '';
|
||||
const needsCert = currentTlsMode === 'terminate' || currentTlsMode === 'terminate-and-reencrypt';
|
||||
const isCustom = currentTlsCert === 'custom';
|
||||
|
||||
const editModal = await DeesModal.createAndShow({
|
||||
heading: `Edit Route: ${route.name}`,
|
||||
content: html`
|
||||
<dees-form>
|
||||
@@ -431,10 +476,18 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'securityProfileRef'} .label=${'Security Profile'} .options=${profileOptions} .selectedKey=${merged.metadata?.securityProfileRef || ''}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedKey=${merged.metadata?.networkTargetRef || ''}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCustomCertGroup" style="display: ${needsCert && isCustom ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'} .value=${currentCustomKey}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'} .value=${currentCustomCert}></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -476,12 +529,35 @@ export class OpsViewRoutes extends DeesElement {
|
||||
...(priority != null && !isNaN(priority) ? { priority } : {}),
|
||||
};
|
||||
|
||||
const metadata: any = {};
|
||||
if (formData.securityProfileRef) {
|
||||
metadata.securityProfileRef = formData.securityProfileRef;
|
||||
// Build TLS config from form
|
||||
const tlsModeValue = formData.tlsMode as any;
|
||||
const tlsModeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
|
||||
if (tlsModeKey && tlsModeKey !== 'none') {
|
||||
const tls: any = { mode: tlsModeKey };
|
||||
if (tlsModeKey !== 'passthrough') {
|
||||
const tlsCertValue = formData.tlsCertificate as any;
|
||||
const tlsCertKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
|
||||
if (tlsCertKey === 'custom' && formData.tlsCertKey && formData.tlsCertCert) {
|
||||
tls.certificate = { key: formData.tlsCertKey, cert: formData.tlsCertCert };
|
||||
} else {
|
||||
tls.certificate = 'auto';
|
||||
}
|
||||
}
|
||||
updatedRoute.action.tls = tls;
|
||||
} else {
|
||||
updatedRoute.action.tls = null; // explicit removal
|
||||
}
|
||||
if (formData.networkTargetRef) {
|
||||
metadata.networkTargetRef = formData.networkTargetRef;
|
||||
|
||||
const metadata: any = {};
|
||||
const profileRefValue = formData.sourceProfileRef as any;
|
||||
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
|
||||
if (profileKey) {
|
||||
metadata.sourceProfileRef = profileKey;
|
||||
}
|
||||
const targetRefValue = formData.networkTargetRef as any;
|
||||
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
}
|
||||
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
@@ -497,6 +573,12 @@ export class OpsViewRoutes extends DeesElement {
|
||||
},
|
||||
],
|
||||
});
|
||||
// Setup conditional TLS field visibility after modal renders
|
||||
const editForm = editModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
|
||||
if (editForm) {
|
||||
await editForm.updateComplete;
|
||||
setupTlsVisibility(editForm);
|
||||
}
|
||||
}
|
||||
|
||||
private async showCreateRouteDialog() {
|
||||
@@ -520,7 +602,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
})),
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
const createModal = await DeesModal.createAndShow({
|
||||
heading: 'Add Programmatic Route',
|
||||
content: html`
|
||||
<dees-form>
|
||||
@@ -528,10 +610,18 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'securityProfileRef'} .label=${'Security Profile'} .options=${profileOptions} .selectedKey=${''}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedKey=${''}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCustomCertGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'}></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -573,13 +663,34 @@ export class OpsViewRoutes extends DeesElement {
|
||||
...(priority != null && !isNaN(priority) ? { priority } : {}),
|
||||
};
|
||||
|
||||
// Build TLS config from form
|
||||
const tlsModeValue = formData.tlsMode as any;
|
||||
const tlsModeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
|
||||
if (tlsModeKey && tlsModeKey !== 'none') {
|
||||
const tls: any = { mode: tlsModeKey };
|
||||
if (tlsModeKey !== 'passthrough') {
|
||||
const tlsCertValue = formData.tlsCertificate as any;
|
||||
const tlsCertKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
|
||||
if (tlsCertKey === 'custom' && formData.tlsCertKey && formData.tlsCertCert) {
|
||||
tls.certificate = { key: formData.tlsCertKey, cert: formData.tlsCertCert };
|
||||
} else {
|
||||
tls.certificate = 'auto';
|
||||
}
|
||||
}
|
||||
route.action.tls = tls;
|
||||
}
|
||||
|
||||
// Build metadata if profile/target selected
|
||||
const metadata: any = {};
|
||||
if (formData.securityProfileRef) {
|
||||
metadata.securityProfileRef = formData.securityProfileRef;
|
||||
const profileRefValue = formData.sourceProfileRef as any;
|
||||
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
|
||||
if (profileKey) {
|
||||
metadata.sourceProfileRef = profileKey;
|
||||
}
|
||||
if (formData.networkTargetRef) {
|
||||
metadata.networkTargetRef = formData.networkTargetRef;
|
||||
const targetRefValue = formData.networkTargetRef as any;
|
||||
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
}
|
||||
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
@@ -594,6 +705,12 @@ export class OpsViewRoutes extends DeesElement {
|
||||
},
|
||||
],
|
||||
});
|
||||
// Setup conditional TLS field visibility after modal renders
|
||||
const createForm = createModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
|
||||
if (createForm) {
|
||||
await createForm.updateComplete;
|
||||
setupTlsVisibility(createForm);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshData() {
|
||||
|
||||
@@ -14,12 +14,12 @@ import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-securityprofiles': OpsViewSecurityProfiles;
|
||||
'ops-view-sourceprofiles': OpsViewSourceProfiles;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-securityprofiles')
|
||||
export class OpsViewSecurityProfiles extends DeesElement {
|
||||
@customElement('ops-view-sourceprofiles')
|
||||
export class OpsViewSourceProfiles extends DeesElement {
|
||||
@state()
|
||||
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
|
||||
|
||||
@@ -58,20 +58,20 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
||||
type: 'number',
|
||||
value: profiles.length,
|
||||
icon: 'lucide:shieldCheck',
|
||||
description: 'Reusable security profiles',
|
||||
description: 'Reusable source profiles',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Security Profiles</ops-sectionheading>
|
||||
<ops-sectionheading>Source Profiles</ops-sectionheading>
|
||||
<div class="profilesContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-table
|
||||
.heading1=${'Security Profiles'}
|
||||
.heading2=${'Reusable security configurations for routes'}
|
||||
.heading1=${'Source Profiles'}
|
||||
.heading2=${'Reusable source configurations for routes'}
|
||||
.data=${profiles}
|
||||
.displayFunction=${(profile: interfaces.data.ISecurityProfile) => ({
|
||||
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
|
||||
Name: profile.name,
|
||||
Description: profile.description || '-',
|
||||
'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-',
|
||||
@@ -107,7 +107,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
||||
iconName: 'lucide:pencil',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const profile = actionData.item as interfaces.data.ISecurityProfile;
|
||||
const profile = actionData.item as interfaces.data.ISourceProfile;
|
||||
await this.showEditProfileDialog(profile);
|
||||
},
|
||||
},
|
||||
@@ -116,7 +116,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const profile = actionData.item as interfaces.data.ISecurityProfile;
|
||||
const profile = actionData.item as interfaces.data.ISourceProfile;
|
||||
await this.deleteProfile(profile);
|
||||
},
|
||||
},
|
||||
@@ -129,7 +129,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
||||
private async showCreateProfileDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Create Security Profile',
|
||||
heading: 'Create Source Profile',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
@@ -167,7 +167,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async showEditProfileDialog(profile: interfaces.data.ISecurityProfile) {
|
||||
private async showEditProfileDialog(profile: interfaces.data.ISourceProfile) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: `Edit Profile: ${profile.name}`,
|
||||
@@ -209,7 +209,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteProfile(profile: interfaces.data.ISecurityProfile) {
|
||||
private async deleteProfile(profile: interfaces.data.ISourceProfile) {
|
||||
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
|
||||
id: profile.id,
|
||||
force: false,
|
||||
379
ts_web/elements/ops-view-targetprofiles.ts
Normal file
379
ts_web/elements/ops-view-targetprofiles.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
DeesElement,
|
||||
html,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
css,
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-targetprofiles': OpsViewTargetProfiles;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-targetprofiles')
|
||||
export class OpsViewTargetProfiles extends DeesElement {
|
||||
@state()
|
||||
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
|
||||
this.targetProfilesState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.profilesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tagBadge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const profiles = this.targetProfilesState.profiles;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalProfiles',
|
||||
title: 'Total Profiles',
|
||||
type: 'number',
|
||||
value: profiles.length,
|
||||
icon: 'lucide:target',
|
||||
description: 'Reusable target profiles',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Target Profiles</ops-sectionheading>
|
||||
<div class="profilesContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-table
|
||||
.heading1=${'Target Profiles'}
|
||||
.heading2=${'Define what resources VPN clients can access'}
|
||||
.data=${profiles}
|
||||
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
|
||||
Name: profile.name,
|
||||
Description: profile.description || '-',
|
||||
Domains: profile.domains?.length
|
||||
? html`${profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)}`
|
||||
: '-',
|
||||
Targets: profile.targets?.length
|
||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)}`
|
||||
: '-',
|
||||
'Route Refs': profile.routeRefs?.length
|
||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
|
||||
: '-',
|
||||
Created: new Date(profile.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Create Profile',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.showCreateProfileDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:rotateCw',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Detail',
|
||||
iconName: 'lucide:info',
|
||||
type: ['doubleClick'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const profile = actionData.item as interfaces.data.ITargetProfile;
|
||||
await this.showDetailDialog(profile);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const profile = actionData.item as interfaces.data.ITargetProfile;
|
||||
await this.showEditProfileDialog(profile);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const profile = actionData.item as interfaces.data.ITargetProfile;
|
||||
await this.deleteProfile(profile);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async showCreateProfileDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Create Target Profile',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} ></dees-input-text>
|
||||
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'}></dees-input-text>
|
||||
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:plus',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
if (!data.name) return;
|
||||
|
||||
const domains = data.domains
|
||||
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const targets = data.targets
|
||||
? String(data.targets).split(',').map((s: string) => {
|
||||
const trimmed = s.trim();
|
||||
const lastColon = trimmed.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
host: trimmed.substring(0, lastColon),
|
||||
port: parseInt(trimmed.substring(lastColon + 1), 10),
|
||||
};
|
||||
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
|
||||
: undefined;
|
||||
const routeRefs = data.routeRefs
|
||||
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
||||
name: String(data.name),
|
||||
description: data.description ? String(data.description) : undefined,
|
||||
domains,
|
||||
targets,
|
||||
routeRefs,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||
const currentDomains = profile.domains?.join(', ') ?? '';
|
||||
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? '';
|
||||
const currentRouteRefs = profile.routeRefs?.join(', ') ?? '';
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: `Edit Profile: ${profile.name}`,
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
|
||||
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} .value=${currentDomains}></dees-input-text>
|
||||
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'} .value=${currentTargets}></dees-input-text>
|
||||
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'} .value=${currentRouteRefs}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
|
||||
const domains = data.domains
|
||||
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targets = data.targets
|
||||
? String(data.targets).split(',').map((s: string) => {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return null;
|
||||
const lastColon = trimmed.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
host: trimmed.substring(0, lastColon),
|
||||
port: parseInt(trimmed.substring(lastColon + 1), 10),
|
||||
};
|
||||
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
|
||||
: [];
|
||||
const routeRefs = data.routeRefs
|
||||
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
||||
id: profile.id,
|
||||
name: String(data.name),
|
||||
description: data.description ? String(data.description) : undefined,
|
||||
domains,
|
||||
targets,
|
||||
routeRefs,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showDetailDialog(profile: interfaces.data.ITargetProfile) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
// Fetch usage (which VPN clients reference this profile)
|
||||
let usageHtml = html`<p style="color: #9ca3af;">Loading usage...</p>`;
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetTargetProfileUsage
|
||||
>('/typedrequest', 'getTargetProfileUsage');
|
||||
const response = await request.fire({
|
||||
identity: appstate.loginStatePart.getState()!.identity!,
|
||||
id: profile.id,
|
||||
});
|
||||
if (response.clients.length > 0) {
|
||||
usageHtml = html`
|
||||
<div style="margin-top: 8px;">
|
||||
${response.clients.map(c => html`
|
||||
<div style="padding: 4px 0; font-size: 13px;">
|
||||
<strong>${c.clientId}</strong>${c.description ? html` - ${c.description}` : ''}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
usageHtml = html`<p style="color: #9ca3af; font-size: 13px;">No VPN clients reference this profile.</p>`;
|
||||
}
|
||||
} catch {
|
||||
usageHtml = html`<p style="color: #9ca3af;">Usage data unavailable.</p>`;
|
||||
}
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: `Target Profile: ${profile.name}`,
|
||||
content: html`
|
||||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<div>
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Description</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">${profile.description || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Domains</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">
|
||||
${profile.domains?.length
|
||||
? profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Targets</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">
|
||||
${profile.targets?.length
|
||||
? profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
<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;">
|
||||
${profile.routeRefs?.length
|
||||
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Updated</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.updatedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">VPN Clients Using This Profile</div>
|
||||
${usageHtml}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteProfile(profile: interfaces.data.ITargetProfile) {
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, {
|
||||
id: profile.id,
|
||||
force: false,
|
||||
});
|
||||
|
||||
const currentState = appstate.targetProfilesStatePart.getState()!;
|
||||
if (currentState.error?.includes('in use')) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Profile In Use',
|
||||
content: html`<p>${currentState.error} Force delete?</p>`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Force Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, {
|
||||
id: profile.id,
|
||||
force: true,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,8 +327,8 @@ export class OpsViewVpn extends DeesElement {
|
||||
'Status': statusHtml,
|
||||
'Routing': routingHtml,
|
||||
'VPN IP': client.assignedIp || '-',
|
||||
'Tags': client.serverDefinedClientTags?.length
|
||||
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||
'Target Profiles': client.targetProfileIds?.length
|
||||
? html`${client.targetProfileIds.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||
: '-',
|
||||
'Description': client.description || '-',
|
||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||
@@ -347,7 +347,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetProfileIds'} .label=${'Target Profile IDs (comma-separated)'}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${true}></dees-input-checkbox>
|
||||
<div class="hostIpGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
|
||||
@@ -383,8 +383,8 @@ export class OpsViewVpn extends DeesElement {
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
if (!data.clientId) return;
|
||||
const serverDefinedClientTags = data.tags
|
||||
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
const targetProfileIds = data.targetProfileIds
|
||||
? data.targetProfileIds.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
// Apply conditional logic based on checkbox states
|
||||
@@ -406,7 +406,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
|
||||
clientId: data.clientId,
|
||||
description: data.description || undefined,
|
||||
serverDefinedClientTags,
|
||||
targetProfileIds,
|
||||
forceDestinationSmartproxy: forceSmartproxy,
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
@@ -479,7 +479,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
||||
` : ''}
|
||||
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.join(', ') || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${client.targetProfileIds?.join(', ') || '-'}</span></div>
|
||||
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</span></div>
|
||||
${client.useHostIp ? html`
|
||||
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
|
||||
@@ -643,7 +643,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const currentDescription = client.description ?? '';
|
||||
const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
|
||||
const currentTargetProfileIds = client.targetProfileIds?.join(', ') ?? '';
|
||||
const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
|
||||
const currentUseHostIp = client.useHostIp ?? false;
|
||||
const currentUseDhcp = client.useDhcp ?? false;
|
||||
@@ -659,7 +659,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'} .value=${currentTags}></dees-input-text>
|
||||
<dees-input-text .key=${'targetProfileIds'} .label=${'Target Profile IDs (comma-separated)'} .value=${currentTargetProfileIds}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${currentForceSmartproxy}></dees-input-checkbox>
|
||||
<div class="hostIpGroup" style="display: ${currentForceSmartproxy ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
|
||||
@@ -690,8 +690,8 @@ export class OpsViewVpn extends DeesElement {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const serverDefinedClientTags = data.tags
|
||||
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
const targetProfileIds = data.targetProfileIds
|
||||
? data.targetProfileIds.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
// Apply conditional logic based on checkbox states
|
||||
@@ -713,7 +713,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
|
||||
clientId: client.clientId,
|
||||
description: data.description || undefined,
|
||||
serverDefinedClientTags,
|
||||
targetProfileIds,
|
||||
forceDestinationSmartproxy: forceSmartproxy,
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
|
||||
@@ -131,7 +131,7 @@ The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled
|
||||
| State Part | Mode | Description |
|
||||
|-----------|------|-------------|
|
||||
| `loginStatePart` | Persistent (IndexedDB) | JWT identity and login status |
|
||||
| `statsStatePart` | Soft (memory) | Server, email, DNS, security metrics |
|
||||
| `statsStatePart` | Soft (memory) | Server, email, DNS, security, RADIUS, VPN metrics |
|
||||
| `configStatePart` | Soft | Current system configuration |
|
||||
| `uiStatePart` | Soft | Active view, sidebar, auto-refresh, theme |
|
||||
| `logStatePart` | Soft | Recent logs, streaming status, filters |
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'securityprofiles', 'networktargets'] as const;
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'sourceprofiles', 'networktargets', 'targetprofiles'] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user