Compare commits

...

8 Commits

Author SHA1 Message Date
jkunz 34bfd1528b v13.36.0
Docker (tags) / release (push) Failing after 1s
2026-05-28 08:48:03 +00:00
jkunz be38808795 feat(network): add top connected ASN activity to network monitoring 2026-05-28 08:47:12 +00:00
jkunz b9ae4ac344 v13.35.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 05:12:13 +00:00
jkunz 37adcc9ddc feat(vpn): use authenticated VPN route grants 2026-05-24 05:11:48 +00:00
jkunz ac118397f9 v13.34.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 23:45:34 +00:00
jkunz 8188b4712c feat(vpn): allow target profiles to grant non-vpnOnly routes by live client source IP 2026-05-21 23:44:01 +00:00
jkunz 27d077feed v13.33.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 01:56:32 +00:00
jkunz 98913c1977 feat(security): add queued IP intelligence observation and filtered retrieval for network and security views 2026-05-21 01:56:17 +00:00
30 changed files with 1126 additions and 185 deletions
+43
View File
@@ -3,10 +3,53 @@
## Pending
## 2026-05-28 - 13.36.0
### Features
- add top connected ASN activity to Network Activity (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose ASN activity through network stats and combined metrics APIs.
- Add a Network Activity table with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
- add top connected ASN activity to network monitoring (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose top ASN activity through network stats and combined metrics API responses.
- Add a Network Activity table for top ASNs with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
## 2026-05-24 - 13.35.0
### Features
- switch VPN route authorization to authenticated SmartVPN metadata (vpn)
- configure SmartVPN to forward real client source IPs plus VPN metadata through trusted PROXY v2 headers
- map target profiles to SmartProxy VPN client grants instead of mutating route source IP allow lists
- keep live VPN client source IP tracking as status/UI data while SmartProxy enforces source policy per connection
## 2026-05-21 - 13.34.0
### Features
- allow VPN target profiles to grant routes by live client source IP (vpn)
- Add an opt-in target profile flag that evaluates non-vpnOnly route source security against the VPN client's real connecting IP.
- Track live VPN client source IPs from smartvpn remote addresses and WireGuard peer endpoints, refreshing routes when they change.
- Expose the setting and current source IPs in the Ops UI with regression coverage for source-IP matching behavior.
- allow target profiles to grant non-vpnOnly routes by live client source IP (vpn)
- add an opt-in target profile flag to match route source security against a VPN client's real connecting IP
- track live client source IPs from VPN remote addresses and WireGuard peer endpoints and re-apply routes when they change
- expose source IP access settings and current client source IPs through the ops API and UI
- add regression tests for source-IP route matching, block-list handling, vpnOnly exclusions, and WireGuard endpoint refresh
## 2026-05-21 - 13.33.0
### Features
- add queued IP intelligence observation and filtered retrieval for network and security views (security)
- Queue observed public IPs from network metrics with throttled background enrichment instead of awaiting lookups during stats collection.
- Allow listing IP intelligence records by specific IP addresses and limit through the security handler and request interface.
- Update web app state to refresh IP intelligence asynchronously in the background and preserve current UI state during refreshes.
- Improve security policy manager observation handling so forced refresh waits for in-flight lookups before fetching updated intelligence.
## 2026-05-20 - 13.32.1
+7 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.32.1",
"version": "13.36.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -22,13 +22,13 @@
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.3.0",
"@git.zone/tsdocker": "^2.4.0",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.0"
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.1",
@@ -53,16 +53,16 @@
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.4.1",
"@push.rocks/smartmta": "^5.3.3",
"@push.rocks/smartnetwork": "^4.7.1",
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.10.3",
"@push.rocks/smartproxy": "^27.11.0",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.4",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
+44 -44
View File
@@ -75,8 +75,8 @@ importers:
specifier: ^5.3.3
version: 5.3.3
'@push.rocks/smartnetwork':
specifier: ^4.7.1
version: 4.7.1
specifier: ^4.7.2
version: 4.7.2
'@push.rocks/smartpath':
specifier: ^6.0.0
version: 6.0.0
@@ -84,8 +84,8 @@ importers:
specifier: ^4.2.4
version: 4.2.4
'@push.rocks/smartproxy':
specifier: ^27.10.3
version: 27.10.3
specifier: ^27.11.0
version: 27.11.0
'@push.rocks/smartradius':
specifier: ^1.1.2
version: 1.1.2
@@ -102,8 +102,8 @@ importers:
specifier: ^3.0.9
version: 3.0.9
'@push.rocks/smartvpn':
specifier: 1.19.4
version: 1.19.4
specifier: 1.20.0
version: 1.20.0
'@push.rocks/taskbuffer':
specifier: ^8.0.2
version: 8.0.2
@@ -133,14 +133,14 @@ importers:
version: 14.0.0
devDependencies:
'@git.zone/tsbuild':
specifier: ^4.4.1
version: 4.4.1
specifier: ^4.4.2
version: 4.4.2
'@git.zone/tsbundle':
specifier: ^2.10.4
version: 2.10.4
'@git.zone/tsdocker':
specifier: ^2.3.0
version: 2.3.0
specifier: ^2.4.0
version: 2.4.0
'@git.zone/tsrun':
specifier: ^2.0.4
version: 2.0.4
@@ -151,8 +151,8 @@ importers:
specifier: ^3.3.5
version: 3.3.5(@tiptap/pm@2.27.2)
'@types/node':
specifier: ^25.9.0
version: 25.9.0
specifier: ^25.9.1
version: 25.9.1
packages:
@@ -718,16 +718,16 @@ packages:
resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==}
engines: {node: '>=6'}
'@git.zone/tsbuild@4.4.1':
resolution: {integrity: sha512-usxx8BBQsAypxjFOfd1GEV9pL9EUshRKktXtRWHMDByb6ps83+PdUIb3D7O+nkkBp4C9PXo3cfbsR4Asvo33CA==}
'@git.zone/tsbuild@4.4.2':
resolution: {integrity: sha512-v2m0fFYFt3vJZMvNAlrNChHYjZZNOf4iyO0mNNiHeO+sTR3cddkYb++zO/GL3v2UkG3nDRwfEkwUS4UzuXBEWw==}
hasBin: true
'@git.zone/tsbundle@2.10.4':
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
hasBin: true
'@git.zone/tsdocker@2.3.0':
resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==}
'@git.zone/tsdocker@2.4.0':
resolution: {integrity: sha512-GFE93RxFm8HDrSm5Ulggy4se7heb4GaNQgaWV6Mds6lhkm6GouO91xZYlmXVH9glzBoFJNG63pFXYHW6nrqf5A==}
hasBin: true
'@git.zone/tspublish@1.11.6':
@@ -1395,8 +1395,8 @@ packages:
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
'@push.rocks/smartnetwork@4.7.1':
resolution: {integrity: sha512-x9SolGn8lU3oh+fKL26dR5dIhsus5f0p/Xiaut2pK5Wamgwrvt5y5To8F+pzF1pQr6yA0XwWZ0Dgoppp2E+ziQ==}
'@push.rocks/smartnetwork@4.7.2':
resolution: {integrity: sha512-OwT8kwQeEO+E3RuCyCfgQEBz+FyydUVaTBivZzzVchdJCUDgoDkXSnRkbIuGoHd1BfRFkUg9DQlSzt0uDfsIbw==}
'@push.rocks/smartnftables@1.2.0':
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
@@ -1422,8 +1422,8 @@ packages:
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.10.3':
resolution: {integrity: sha512-2TvjgXUHtV0s8WH2RbtCS5+yjnFjbvQQ2ROmtVme1lgt2GUaAbekozUJNTE1ZMLEXc4xcZRdXIOfgBcQ6j/dmQ==}
'@push.rocks/smartproxy@27.11.0':
resolution: {integrity: sha512-ruyUMbrk28BTtrhcZpB5fX35FRQyyhJgVd7snPFa3Zttw0N8ahYrwKXpKfuagvOcaIpORMQoyR5WSv0C2ATFVA==}
'@push.rocks/smartpuppeteer@2.0.6':
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
@@ -1482,8 +1482,8 @@ packages:
'@push.rocks/smartversion@3.1.0':
resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==}
'@push.rocks/smartvpn@1.19.4':
resolution: {integrity: sha512-Cp6yyzRcZlqQMEWAQ/CG2tvUxSR4eSmzMTDQFVJsPtV+CbhXpulbqqz0penU6drVMiRGzXhwoQZtGYynigIXwA==}
'@push.rocks/smartvpn@1.20.0':
resolution: {integrity: sha512-k5cdbHGtCUMcZTwJr+7BwXNFxbeXZEe5MZ00y/f2Isi8yLAdfmdBJ5o32vwR0LJvWm2ZFn7ST8S1AkCY/K9L3w==}
'@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
@@ -2158,8 +2158,8 @@ packages:
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@25.9.0':
resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==}
'@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@@ -5194,7 +5194,7 @@ snapshots:
dependencies:
'@fortawesome/fontawesome-common-types': 7.2.0
'@git.zone/tsbuild@4.4.1':
'@git.zone/tsbuild@4.4.2':
dependencies:
'@git.zone/tspublish': 1.11.6
'@push.rocks/early': 4.0.4
@@ -5243,7 +5243,7 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdocker@2.3.0':
'@git.zone/tsdocker@2.4.0':
dependencies:
'@push.rocks/lik': 6.4.1
'@push.rocks/projectinfo': 5.1.0
@@ -5306,7 +5306,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.1
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 7.0.0(socks@2.8.8)
'@push.rocks/smartnetwork': 4.7.1
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrequest': 5.0.3
@@ -5370,7 +5370,7 @@ snapshots:
'@happy-dom/global-registrator@20.9.0':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
happy-dom: 20.9.0
transitivePeerDependencies:
- bufferutil
@@ -6115,7 +6115,7 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartdns': 7.9.2
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartnetwork': 4.7.1
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartstring': 4.1.1
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
@@ -6591,7 +6591,7 @@ snapshots:
dependencies:
handlebars: 4.7.9
'@push.rocks/smartnetwork@4.7.1':
'@push.rocks/smartnetwork@4.7.2':
dependencies:
'@push.rocks/smartdns': 7.9.2
'@push.rocks/smartrust': 1.4.0
@@ -6654,7 +6654,7 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartfs': 1.5.1
'@push.rocks/smartjimp': 1.2.1
'@push.rocks/smartnetwork': 4.7.1
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartpuppeteer': 2.0.6(typescript@6.0.3)
@@ -6675,7 +6675,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.10.3':
'@push.rocks/smartproxy@27.11.0':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -6822,7 +6822,7 @@ snapshots:
'@types/semver': 7.7.1
semver: 7.7.4
'@push.rocks/smartvpn@1.19.4':
'@push.rocks/smartvpn@1.20.0':
dependencies:
'@push.rocks/smartnftables': 1.2.0
'@push.rocks/smartpath': 6.0.0
@@ -7583,7 +7583,7 @@ snapshots:
'@types/clean-css@4.2.11':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
source-map: 0.6.1
'@types/debug@4.1.13':
@@ -7611,7 +7611,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/linkify-it@5.0.0': {}
@@ -7632,16 +7632,16 @@ snapshots:
'@types/mute-stream@0.0.4':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
form-data: 4.0.5
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/node@16.9.1': {}
@@ -7653,13 +7653,13 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@25.9.0':
'@types/node@25.9.1':
dependencies:
undici-types: 7.24.6
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/randomatic@3.1.5': {}
@@ -7671,7 +7671,7 @@ snapshots:
'@types/through2@2.0.41':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/trusted-types@2.0.7': {}
@@ -7699,11 +7699,11 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
optional: true
'@ungap/structured-clone@1.3.1': {}
@@ -8423,7 +8423,7 @@ snapshots:
happy-dom@20.9.0:
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
+13
View File
@@ -196,6 +196,19 @@ const router = new DcRouter({
await router.start();
```
## VPN Target Profiles
Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also be granted to routes whose source policy is meant to evaluate the client's real connecting IP.
dcrouter maps target profiles to SmartProxy VPN client grants. SmartVPN forwards both the real client source IP and authenticated VPN metadata through trusted PROXY v2 headers, so SmartProxy checks source policy and VPN client authorization separately for each connection. Route `security.ipAllowList` and `security.ipBlockList` stay the source of truth for real source-IP policy; `vpnOnly` adds the requirement for authenticated VPN metadata and a matching VPN client grant.
```typescript
const targetProfile = {
name: 'ops laptop source access',
allowRoutesByClientSourceIp: true,
};
```
## Automation API
The OpsServer exposes TypedRequest handlers at `/typedrequest`. You can use raw contracts or the object-oriented API client.
+90 -3
View File
@@ -22,14 +22,21 @@ function createProxyMetrics(args: {
backendMetrics?: Map<string, any>;
protocolCache?: any[];
requestsTotal?: number;
connectionsByIP?: Map<string, number>;
throughputByIP?: Map<string, { in: number; out: number }>;
}) {
const connectionsByIP = args.connectionsByIP || new Map<string, number>();
const throughputByIP = args.throughputByIP || new Map<string, { in: number; out: number }>();
return {
connections: {
active: () => 0,
total: () => 0,
byRoute: () => args.connectionsByRoute,
byIP: () => new Map<string, number>(),
topIPs: () => [],
byIP: () => connectionsByIP,
topIPs: (limit = 10) => Array.from(connectionsByIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([ip, count]) => ({ ip, count })),
domainRequestsByIP: () => args.domainRequestsByIP,
topDomainRequests: () => [],
frontendProtocols: () => emptyProtocolDistribution,
@@ -42,7 +49,7 @@ function createProxyMetrics(args: {
custom: () => ({ in: 0, out: 0 }),
history: () => [],
byRoute: () => args.throughputByRoute,
byIP: () => new Map<string, { in: number; out: number }>(),
byIP: () => throughputByIP,
},
requests: {
perSecond: () => 0,
@@ -239,4 +246,84 @@ tap.test('MetricsManager does not duplicate backend active counts onto protocol
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
});
tap.test('MetricsManager queues IP intelligence without awaiting enrichment', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['1.1.1.1', 2],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['1.1.1.1', { in: 1500, out: 1000 }],
]),
});
const queuedIps: string[][] = [];
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
listIpIntelligence: async () => [],
},
} as any);
await manager.getNetworkStats();
expect(queuedIps).toHaveLength(1);
expect(queuedIps[0]).toContain('8.8.8.8');
expect(queuedIps[0]).toContain('1.1.1.1');
});
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['8.8.4.4', 3],
['1.1.1.1', 5],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['8.8.4.4', { in: 700, out: 350 }],
['1.1.1.1', { in: 2000, out: 1000 }],
]),
});
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: () => undefined,
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
},
} as any);
const stats = await manager.getNetworkStats();
expect(stats.topASNs).toHaveLength(2);
expect(stats.topASNs[0].asn).toEqual(15169);
expect(stats.topASNs[0].organization).toEqual('Google LLC');
expect(stats.topASNs[0].activeConnections).toEqual(7);
expect(stats.topASNs[0].ipCount).toEqual(2);
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
expect(stats.topASNs[1].asn).toEqual(13335);
expect(stats.topASNs[1].activeConnections).toEqual(5);
});
export default tap.start();
+71
View File
@@ -40,6 +40,23 @@ const clearTestState = async () => {
}
};
const createIntelligenceResult = (asn: number) => ({
asn,
asnOrg: `ASN ${asn}`,
registrantOrg: null,
registrantCountry: null,
networkRange: null,
networkCidrs: null,
abuseContact: null,
country: null,
countryCode: 'US',
city: null,
latitude: null,
longitude: null,
accuracyRadius: null,
timezone: null,
});
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
await testDbPromise;
await clearTestState();
@@ -120,6 +137,60 @@ tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot
expect(firewall).toEqual({ blockedIps: [] });
});
tap.test('SecurityPolicyManager filters listed IP intelligence records', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager();
for (const [ipAddress, asn] of [['8.8.8.8', 15169], ['1.1.1.1', 13335]] as const) {
const intelligenceDoc = new IpIntelligenceDoc();
intelligenceDoc.ipAddress = ipAddress;
intelligenceDoc.asn = asn;
intelligenceDoc.asnOrg = `ASN ${asn}`;
intelligenceDoc.firstSeenAt = Date.now();
intelligenceDoc.lastSeenAt = Date.now();
intelligenceDoc.updatedAt = Date.now();
intelligenceDoc.seenCount = 1;
await intelligenceDoc.save();
}
const records = await manager.listIpIntelligence({ ipAddresses: ['1.1.1.1'] });
expect(records).toHaveLength(1);
expect(records[0].ipAddress).toEqual('1.1.1.1');
});
tap.test('SecurityPolicyManager force refresh waits for an in-flight background observation', async () => {
await testDbPromise;
await clearTestState();
const manager = new SecurityPolicyManager({ intelligenceRefreshMs: 0 });
let releaseFirstLookup!: () => void;
let lookupCount = 0;
(manager as any).smartNetwork = {
getIpIntelligence: async () => {
lookupCount++;
if (lookupCount === 1) {
await new Promise<void>((resolve) => { releaseFirstLookup = resolve; });
return createIntelligenceResult(64500);
}
return createIntelligenceResult(64501);
},
stop: async () => {},
};
const backgroundObservation = manager.observeIp('8.8.8.8');
await new Promise((resolve) => setTimeout(resolve, 10));
const forcedRefresh = manager.refreshIpIntelligence('8.8.8.8');
releaseFirstLookup();
const record = await forcedRefresh;
await backgroundObservation;
expect(lookupCount).toEqual(2);
expect(record?.asn).toEqual(64501);
});
tap.test('cleanup security policy test db', async () => {
const dbHandle = await testDbPromise;
await clearTestState();
+225 -11
View File
@@ -77,7 +77,7 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
},
} as any;
(dcRouter as any).routeConfigManager = {
setVpnClientIpsResolver: (resolver: unknown) => {
setVpnClientAccessResolver: (resolver: unknown) => {
resolverValues.push(resolver);
},
applyRoutes: async () => {
@@ -121,15 +121,15 @@ tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN client
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual([]);
expect(prepared.security.ipBlockList).toContain('*');
expect(prepared.security.ipAllowList).toEqual(['*']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
});
tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', async () => {
tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['10.8.0.2'],
() => ['client-1'],
);
const route = {
name: 'private-route',
@@ -144,15 +144,16 @@ tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', as
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['10.8.0.2']);
expect(prepared.security.ipAllowList).toEqual(['*', '203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
});
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['10.8.0.2'],
() => ['client-1'],
);
const route = {
name: 'shared-private-route',
@@ -166,8 +167,9 @@ tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10', '10.8.0.2']);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
});
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
@@ -181,17 +183,17 @@ tap.test('TargetProfileManager matches wildcard profiles against string route do
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
const entries = manager.getMatchingVpnClients(
{
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
);
expect(entries).toEqual(['10.8.0.2']);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
@@ -227,6 +229,218 @@ tap.test('TargetProfileManager expands wildcard profile domains to matching conc
expect(accessSpec.domains).toContain('app.hagen.team');
});
tap.test('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager leaves real source-IP enforcement to SmartProxy', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager does not grant routes with wildcard source block', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'blocked-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: {
ipAllowList: ['203.0.113.0/24'],
ipBlockList: ['*'],
},
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual([]);
});
tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'public-route',
match: { domains: 'public.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager grants vpnOnly routes through source-policy profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const entries = manager.getMatchingVpnClients(
{
name: 'vpn-only-route',
vpnOnly: true,
match: { domains: 'private.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.10'] },
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
name: 'source-ip access',
allowRoutesByClientSourceIp: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
});
const routes = new Map([
['route-1', {
id: 'route-1',
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
route: {
name: 'source-reachable-app',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: { ipAllowList: ['203.0.113.0/24'] },
},
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('app.example.com');
});
tap.test('VpnManager normalizes real remote addresses', async () => {
expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10');
expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1');
expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1');
});
tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => {
const manager = new VpnManager({});
let sourceIpChangeCalls = 0;
(manager as any).config.onClientSourceIpsChanged = () => {
sourceIpChangeCalls++;
};
(manager as any).clients = new Map([
['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }],
]);
(manager as any).vpnServer = {
listClients: async () => ([
{
clientId: 'runtime-client-1',
registeredClientId: 'client-1',
assignedIp: '10.8.0.2',
transportType: 'wireguard',
},
]),
listWgPeers: async () => ([
{
publicKey: 'wg-public-key',
allowedIps: ['10.8.0.2/32'],
endpoint: '198.51.100.44:61234',
bytesSent: 0,
bytesReceived: 0,
packetsSent: 0,
packetsReceived: 0,
},
]),
};
const changed = await manager.refreshClientSourceIps();
const changedAgain = await manager.refreshClientSourceIps();
expect(changed).toEqual(true);
expect(changedAgain).toEqual(false);
expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44');
expect(sourceIpChangeCalls).toEqual(1);
});
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
const manager = new VpnManager({
serverEndpoint: 'vpn.example.com',
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.32.1',
version: '13.36.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+15 -10
View File
@@ -26,7 +26,7 @@ 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, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js';
@@ -605,7 +605,7 @@ export class DcRouter {
this.routeConfigManager = new RouteConfigManager(
() => this.smartProxy,
() => this.options.http3,
this.createVpnRouteAllowListResolver(),
this.createVpnClientAccessResolver(),
this.referenceResolver,
// Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary
@@ -2399,10 +2399,10 @@ export class DcRouter {
/**
* Set up VPN server for VPN-based route access control.
*/
private createVpnRouteAllowListResolver(): ((
private createVpnClientAccessResolver(): ((
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string,
) => TIpAllowEntry[]) | undefined {
) => TVpnClientAllowEntry[]) | undefined {
if (!this.options.vpnConfig?.enabled) {
return undefined;
}
@@ -2416,7 +2416,7 @@ export class DcRouter {
return [];
}
return this.targetProfileManager.getMatchingClientIps(
return this.targetProfileManager.getMatchingVpnClients(
route,
routeId,
this.vpnManager.listClients(),
@@ -2452,17 +2452,21 @@ export class DcRouter {
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
onClientChanged: () => {
// Re-apply routes so profile-based ipAllowLists get updated
// Re-apply routes so profile-based VPN client grants get updated
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
this.routeConfigManager?.applyRoutes().catch((err) => {
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
});
},
onClientSourceIpsChanged: () => {
// SmartProxy now receives the real source IP per connection via PROXY v2.
// Source-IP changes are reflected in status/UI only; route config is static.
},
getClientDirectTargets: (targetProfileIds: string[]) => {
if (!this.targetProfileManager) return [];
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
},
getClientAllowedIPs: async (targetProfileIds: string[]) => {
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, _sourceIp?: string) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]);
@@ -2471,7 +2475,8 @@ export class DcRouter {
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, allRoutes,
targetProfileIds,
allRoutes,
);
// Add target IPs directly
@@ -2498,7 +2503,7 @@ export class DcRouter {
await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
// get correct profile-based ipAllowLists
// get correct profile-based VPN client grants.
await this.routeConfigManager?.applyRoutes();
}
@@ -2594,7 +2599,7 @@ export class DcRouter {
this.options.vpnConfig = config;
this.vpnDomainIpCache.clear();
this.warnedWildcardVpnDomains.clear();
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver());
this.routeConfigManager?.setVpnClientAccessResolver(this.createVpnClientAccessResolver());
if (this.options.vpnConfig?.enabled) {
await this.setupVpnServer();
+25 -33
View File
@@ -11,8 +11,7 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] };
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
export interface IRouteMutationResult {
success: boolean;
@@ -57,7 +56,7 @@ export class RouteConfigManager {
constructor(
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
@@ -73,10 +72,10 @@ export class RouteConfigManager {
return this.routes.get(id);
}
public setVpnClientIpsResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
public setVpnClientAccessResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
): void {
this.getVpnClientIpsForRoute = resolver;
this.getVpnClientAccessForRoute = resolver;
}
/**
@@ -608,49 +607,42 @@ export class RouteConfigManager {
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const dcRoute = route as IDcRouterRouteConfig;
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
if (!dcRoute.vpnOnly) {
const existingAllowList = route.security?.ipAllowList;
if (!Array.isArray(existingAllowList) || existingAllowList.length === 0 || vpnEntries.length === 0) {
return route;
}
return {
...route,
security: {
...route.security,
ipAllowList: this.mergeIpAllowEntries(existingAllowList as TIpAllowEntry[], vpnEntries),
},
};
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
return route;
}
const existingBlockList = route.security?.ipBlockList || [];
const ipBlockList = vpnEntries.length
? existingBlockList
: [...new Set([...existingBlockList, '*'])];
const existingVpnSecurity = route.security?.vpn || {};
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
existingVpnSecurity.allowedClients || [],
vpnEntries,
);
return {
...route,
security: {
...route.security,
ipAllowList: vpnEntries,
ipBlockList,
vpn: {
...existingVpnSecurity,
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
allowedClients: mergedAllowedClients,
},
},
};
}
private mergeIpAllowEntries(
existingEntries: TIpAllowEntry[],
vpnEntries: TIpAllowEntry[],
): TIpAllowEntry[] {
const merged: TIpAllowEntry[] = [];
private mergeVpnClientAllowEntries(
existingEntries: TVpnClientAllowEntry[],
vpnEntries: TVpnClientAllowEntry[],
): TVpnClientAllowEntry[] {
const merged: TVpnClientAllowEntry[] = [];
const seen = new Set<string>();
for (const entry of [...existingEntries, ...vpnEntries]) {
const key = typeof entry === 'string'
? `ip:${entry}`
: `domain:${entry.ip}:${[...entry.domains].sort().join(',')}`;
? `client:${entry}`
: `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
+48 -14
View File
@@ -5,6 +5,8 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
/**
* Manages TargetProfiles (target-side: what can be accessed).
* TargetProfiles define what resources a VPN client can reach:
@@ -35,6 +37,7 @@ export class TargetProfileManager {
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
createdBy: string;
}): Promise<string> {
// Enforce unique profile names
@@ -55,6 +58,7 @@ export class TargetProfileManager {
domains: data.domains,
targets: data.targets,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
@@ -88,6 +92,9 @@ export class TargetProfileManager {
if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
if (patch.allowRoutesByClientSourceIp !== undefined) {
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
}
profile.updatedAt = Date.now();
await this.persistProfile(profile);
@@ -199,29 +206,30 @@ export class TargetProfileManager {
}
// =========================================================================
// Core matching: route → client IPs
// Core matching: route → VPN client grants
// =========================================================================
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns IP allow entries for injection into ipAllowList.
* Find all enabled VPN clients whose assigned TargetProfile matches the route.
* Returns SmartProxy VPN client allow entries for authenticated metadata checks.
*
* Entries are domain-scoped when a profile matches via specific domains that are
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
* or when profile domains exactly equal the route's domains.
* or when profile domains exactly equal the route's domains. Profiles can also opt
* into source-policy routes; SmartProxy evaluates the real source IP per connection.
*/
public getMatchingClientIps(
public getMatchingVpnClients(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(),
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
): TVpnClientAllowEntry[] {
const entries: TVpnClientAllowEntry[] = [];
const routeDomains = this.getRouteDomains(route);
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue;
if (!client.enabled || !client.clientId) continue;
if (!client.targetProfileIds?.length) continue;
// Collect scoped domains from all matching profiles for this client
@@ -246,12 +254,20 @@ export class TargetProfileManager {
if (matchResult !== 'none') {
for (const d of matchResult.domains) scopedDomains.add(d);
}
if (
profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(route)
) {
fullAccess = true;
break;
}
}
if (fullAccess) {
entries.push(client.assignedIp);
entries.push(client.clientId);
} else if (scopedDomains.size > 0) {
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
entries.push({ clientId: client.clientId, domains: [...scopedDomains] });
}
}
@@ -292,13 +308,18 @@ export class TargetProfileManager {
// Route references: scan all routes
for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue;
if (this.routeMatchesProfile(
route.route as IDcRouterRouteConfig,
const dcRoute = route.route as IDcRouterRouteConfig;
const routeDomains = this.getRouteDomains(dcRoute);
const profileMatchesRoute = this.routeMatchesProfile(
dcRoute,
routeId,
profile,
routeNameIndex,
)) {
for (const d of this.getRouteDomains(route.route as IDcRouterRouteConfig)) {
);
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(dcRoute);
if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) {
domains.add(d);
}
}
@@ -422,6 +443,16 @@ export class TargetProfileManager {
return false;
}
private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
const security = (route as any).security;
const blockEntries = Array.isArray(security?.ipBlockList)
? security.ipBlockList
: security?.ipBlockList
? [security.ipBlockList]
: [];
return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
}
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
const domains = (route.match as any)?.domains;
if (!domains) return [];
@@ -503,6 +534,7 @@ export class TargetProfileManager {
domains: doc.domains,
targets: doc.targets,
routeRefs: doc.routeRefs,
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
@@ -522,6 +554,7 @@ export class TargetProfileManager {
existingDoc.domains = profile.domains;
existingDoc.targets = profile.targets;
existingDoc.routeRefs = profile.routeRefs;
existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
@@ -532,6 +565,7 @@ export class TargetProfileManager {
doc.domains = profile.domains;
doc.targets = profile.targets;
doc.routeRefs = profile.routeRefs;
doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
@plugins.smartdata.svDb()
public routeRefs?: string[];
@plugins.smartdata.svDb()
public allowRoutesByClientSourceIp?: boolean;
@plugins.smartdata.svDb()
public createdAt!: number;
+67 -2
View File
@@ -3,6 +3,7 @@ import { DcRouter } from '../classes.dcrouter.js';
import { MetricsCache } from './classes.metricscache.js';
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
import { logger } from '../logger.js';
import type { IAsnActivity } from '../../ts_interfaces/data/stats.js';
export class MetricsManager {
private metricsLogger: plugins.smartlog.Smartlog;
@@ -545,7 +546,7 @@ export class MetricsManager {
// Get network metrics from SmartProxy
public async getNetworkStats() {
// Use shorter cache TTL for network stats to ensure real-time updates
return this.metricsCache.get('networkStats', () => {
return this.metricsCache.get('networkStats', async () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
@@ -554,6 +555,7 @@ export class MetricsManager {
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
topASNs: [] as IAsnActivity[],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(),
@@ -725,7 +727,15 @@ export class MetricsManager {
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
const observedIps = [...new Set([
...connectionsByIP.keys(),
...throughputByIP.keys(),
...topIPs.map((item) => item.ip),
...topIPsByBandwidth.map((item) => item.ip),
])];
this.dcRouter.securityPolicyManager?.queueObservedIps(observedIps);
const topASNs = await this.buildTopASNs(observedIps, allIPData);
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
@@ -869,6 +879,7 @@ export class MetricsManager {
throughputRate,
topIPs,
topIPsByBandwidth,
topASNs,
totalDataTransferred,
throughputHistory,
throughputByIP,
@@ -882,6 +893,60 @@ export class MetricsManager {
}, 1000); // 1s cache — matches typical dashboard poll interval
}
private async buildTopASNs(
observedIps: string[],
allIPData: Map<string, { count: number; bwIn: number; bwOut: number }>,
): Promise<IAsnActivity[]> {
const manager = this.dcRouter.securityPolicyManager;
if (!manager || observedIps.length === 0) {
return [];
}
const intelligenceRecords = await manager.listIpIntelligence({
ipAddresses: observedIps,
limit: Math.max(100, observedIps.length),
});
const asnActivity = new Map<number, IAsnActivity>();
for (const record of intelligenceRecords) {
if (typeof record.asn !== 'number') continue;
const ipData = allIPData.get(record.ipAddress);
if (!ipData) continue;
const existing = asnActivity.get(record.asn);
const activity = existing || {
asn: record.asn,
organization: record.asnOrg || record.registrantOrg || `AS${record.asn}`,
country: record.countryCode || record.country || record.registrantCountry || null,
activeConnections: 0,
ipCount: 0,
bytesInPerSecond: 0,
bytesOutPerSecond: 0,
sampleIps: [],
};
activity.activeConnections += ipData.count;
activity.bytesInPerSecond += ipData.bwIn;
activity.bytesOutPerSecond += ipData.bwOut;
activity.ipCount++;
if (activity.sampleIps.length < 5) {
activity.sampleIps.push(record.ipAddress);
}
asnActivity.set(record.asn, activity);
}
return [...asnActivity.values()]
.sort((a, b) => {
const connectionDiff = b.activeConnections - a.activeConnections;
if (connectionDiff !== 0) return connectionDiff;
const bandwidthA = a.bytesInPerSecond + a.bytesOutPerSecond;
const bandwidthB = b.bytesInPerSecond + b.bytesOutPerSecond;
return bandwidthB - bandwidthA;
})
.slice(0, 10);
}
// --- Time-series helpers ---
private static minuteKey(ts: number = Date.now()): number {
+10 -1
View File
@@ -103,6 +103,7 @@ export class SecurityHandler {
throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
topASNs: networkStats.topASNs,
totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
@@ -121,6 +122,7 @@ export class SecurityHandler {
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
topIPsByBandwidth: [],
topASNs: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
@@ -180,7 +182,14 @@ export class SecurityHandler {
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
return { records: manager ? await manager.listIpIntelligence() : [] };
return {
records: manager
? await manager.listIpIntelligence({
ipAddresses: dataArg.ipAddresses,
limit: dataArg.limit,
})
: [],
};
},
),
);
+1
View File
@@ -334,6 +334,7 @@ export class StatsHandler {
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
topASNs: stats.topASNs || [],
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
@@ -69,6 +69,7 @@ export class TargetProfileHandler {
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
createdBy: userId,
});
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
@@ -94,6 +95,7 @@ export class TargetProfileHandler {
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
});
// Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
+2
View File
@@ -102,6 +102,8 @@ export class VpnHandler {
bytesSent: c.bytesSent,
bytesReceived: c.bytesReceived,
transport: c.transportType,
remoteAddr: c.remoteAddr,
sourceIp: manager.getClientSourceIp(c.registeredClientId || c.clientId),
})),
};
},
+115 -7
View File
@@ -19,12 +19,24 @@ export interface IRemoteIngressFirewallSnapshot {
blockedIps: string[];
}
const OBSERVED_IP_QUEUE_LIMIT = 512;
const OBSERVED_IP_BATCH_LIMIT = 20;
const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
export class SecurityPolicyManager {
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
cacheTtl: 24 * 60 * 60 * 1000,
ipIntelligenceTimeout: 5_000,
});
private readonly intelligenceRefreshMs: number;
private readonly inFlightObservations = new Set<string>();
private readonly inFlightObservations = new Map<string, Promise<void>>();
private readonly queuedObservations = new Set<string>();
private readonly observationQueue: string[] = [];
private readonly lastQueuedAt = new Map<string, number>();
private activeQueuedObservations = 0;
private queueDrainScheduled = false;
private isStopping = false;
private readonly onPolicyChanged?: () => void | Promise<void>;
constructor(options: ISecurityPolicyManagerOptions = {}) {
@@ -37,6 +49,9 @@ export class SecurityPolicyManager {
}
public async stop(): Promise<void> {
this.isStopping = true;
this.observationQueue.length = 0;
this.queuedObservations.clear();
await this.smartNetwork.stop();
}
@@ -45,13 +60,55 @@ export class SecurityPolicyManager {
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
}
public queueObservedIps(ips: string[]): void {
if (this.isStopping) return;
const now = Date.now();
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
for (const ip of uniqueIps.slice(0, OBSERVED_IP_BATCH_LIMIT)) {
if (!this.isPublicIp(ip)) continue;
if (this.inFlightObservations.has(ip) || this.queuedObservations.has(ip)) continue;
const lastQueuedAt = this.lastQueuedAt.get(ip);
if (lastQueuedAt && now - lastQueuedAt < OBSERVED_IP_REQUEUE_THROTTLE_MS) continue;
if (this.observationQueue.length >= OBSERVED_IP_QUEUE_LIMIT) {
const droppedIp = this.observationQueue.shift();
if (droppedIp) this.queuedObservations.delete(droppedIp);
}
this.observationQueue.push(ip);
this.queuedObservations.add(ip);
this.lastQueuedAt.set(ip, now);
}
this.pruneQueuedIpMemory(now);
this.scheduleQueueDrain();
}
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
const ip = this.normalizeIp(ipAddress);
if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) {
if (!ip || !this.isPublicIp(ip)) {
return;
}
this.inFlightObservations.add(ip);
const existingObservation = this.inFlightObservations.get(ip);
if (existingObservation) {
await existingObservation;
if (!options.force) return;
}
const observationPromise = this.performObserveIp(ip, options).finally(() => {
if (this.inFlightObservations.get(ip) === observationPromise) {
this.inFlightObservations.delete(ip);
}
});
this.inFlightObservations.set(ip, observationPromise);
await observationPromise;
}
private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
try {
const now = Date.now();
let doc = await IpIntelligenceDoc.findByIp(ip);
@@ -81,8 +138,6 @@ export class SecurityPolicyManager {
}
} catch (err) {
logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
} finally {
this.inFlightObservations.delete(ip);
}
}
@@ -90,8 +145,22 @@ export class SecurityPolicyManager {
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
}
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(doc));
public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
const limit = Number.isInteger(options.limit) && options.limit! > 0
? Math.min(options.limit!, 500)
: undefined;
let docs: IpIntelligenceDoc[];
if (options.ipAddresses?.length) {
const ips = [...new Set(options.ipAddresses.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
const results = await Promise.all(ips.map((ip) => IpIntelligenceDoc.findByIp(ip)));
docs = results.filter(Boolean) as IpIntelligenceDoc[];
} else {
docs = await IpIntelligenceDoc.findAll();
}
const sortedDocs = docs.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
return (limit ? sortedDocs.slice(0, limit) : sortedDocs).map((doc) => this.intelligenceFromDoc(doc));
}
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
@@ -104,6 +173,45 @@ export class SecurityPolicyManager {
return doc ? this.intelligenceFromDoc(doc) : null;
}
private scheduleQueueDrain(): void {
if (this.queueDrainScheduled || this.isStopping) return;
this.queueDrainScheduled = true;
setTimeout(() => {
this.queueDrainScheduled = false;
this.drainObservationQueue();
}, 0);
}
private drainObservationQueue(): void {
if (this.isStopping) return;
while (
this.activeQueuedObservations < OBSERVED_IP_QUEUE_CONCURRENCY &&
this.observationQueue.length > 0
) {
const ip = this.observationQueue.shift()!;
this.queuedObservations.delete(ip);
this.activeQueuedObservations++;
void this.observeIp(ip)
.catch(() => undefined)
.finally(() => {
this.activeQueuedObservations--;
if (this.observationQueue.length > 0) {
this.scheduleQueueDrain();
}
});
}
}
private pruneQueuedIpMemory(now: number): void {
if (this.lastQueuedAt.size <= OBSERVED_IP_QUEUE_LIMIT * 2) return;
for (const [ip, lastQueuedAt] of this.lastQueuedAt) {
if (now - lastQueuedAt > OBSERVED_IP_REQUEUE_THROTTLE_MS * 2) {
this.lastQueuedAt.delete(ip);
}
}
}
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
id: doc.id,
+160 -3
View File
@@ -19,6 +19,10 @@ export interface IVpnManagerConfig {
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Called when a live VPN client's real source IP changes. */
onClientSourceIpsChanged?: () => void;
/** Poll interval for live VPN client real source IP updates. Default: 10 seconds. */
clientSourceIpPollIntervalMs?: number;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
@@ -29,7 +33,7 @@ export interface IVpnManagerConfig {
/** 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?: (targetProfileIds: string[]) => Promise<string[]>;
getClientAllowedIPs?: (targetProfileIds: string[], clientId?: string, sourceIp?: string) => Promise<string[]>;
/** Resolve per-client destination allow-list IPs from target profile IDs.
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
@@ -57,6 +61,9 @@ export class VpnManager {
private serverKeys?: VpnServerKeysDoc;
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
private clientSourceIps = new Map<string, string>();
private clientSourceIpPollTimer?: ReturnType<typeof setInterval>;
private clientSourceIpRefreshInFlight = false;
constructor(config: IVpnManagerConfig) {
this.config = config;
@@ -145,6 +152,8 @@ export class VpnManager {
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: !isBridge,
socketForwardProxyProtocolSource: 'remoteIp',
socketForwardProxyProtocolVpnMetadata: true,
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
serverEndpoint,
clientAllowedIPs: [subnet],
@@ -173,6 +182,9 @@ export class VpnManager {
}
}
await this.refreshClientSourceIps(false);
this.startClientSourceIpPolling();
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
@@ -180,6 +192,7 @@ export class VpnManager {
* Stop the VPN server.
*/
public async stop(): Promise<void> {
this.stopClientSourceIpPolling();
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
@@ -189,6 +202,11 @@ export class VpnManager {
await this.vpnServer.stop();
this.vpnServer = undefined;
}
const hadClientSourceIps = this.clientSourceIps.size > 0;
this.clientSourceIps.clear();
if (hadClientSourceIps) {
this.config.onClientSourceIpsChanged?.();
}
this.resolvedForwardingMode = undefined;
logger.log('info', 'VPN server stopped');
}
@@ -246,6 +264,7 @@ export class VpnManager {
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
doc.targetProfileIds || [],
doc.clientId,
);
// Persist client entry (including WG private key for export/QR)
@@ -287,6 +306,7 @@ export class VpnManager {
await this.vpnServer.removeClient(clientId);
const doc = this.clients.get(clientId);
this.clients.delete(clientId);
this.clientSourceIps.delete(clientId);
if (doc) {
await doc.delete();
}
@@ -328,6 +348,7 @@ export class VpnManager {
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.clientSourceIps.delete(clientId);
this.config.onClientChanged?.();
}
@@ -380,6 +401,7 @@ export class VpnManager {
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
client?.targetProfileIds || [],
clientId,
);
// Update persisted entry with new keys (including private key for export/QR)
@@ -413,7 +435,11 @@ export class VpnManager {
);
}
config = await this.rewriteWireGuardAllowedIPs(config, persisted?.targetProfileIds || []);
config = await this.rewriteWireGuardAllowedIPs(
config,
persisted?.targetProfileIds || [],
clientId,
);
}
return config;
@@ -445,6 +471,107 @@ export class VpnManager {
return this.vpnServer.listClients();
}
public getClientSourceIp(clientId: string): string | undefined {
return this.clientSourceIps.get(clientId);
}
public getClientSourceIpMap(): Map<string, string> {
return new Map(this.clientSourceIps);
}
public async refreshClientSourceIps(notifyOnChange = true): Promise<boolean> {
if (!this.vpnServer || this.clientSourceIpRefreshInFlight) {
return false;
}
this.clientSourceIpRefreshInFlight = true;
try {
const connectedClients = await this.vpnServer.listClients();
const nextSourceIps = new Map<string, string>();
const wireguardClientIds = new Set<string>();
for (const connectedClient of connectedClients) {
const clientId = connectedClient.registeredClientId || connectedClient.clientId;
if (!clientId) continue;
if (connectedClient.transportType === 'wireguard') {
wireguardClientIds.add(clientId);
}
const sourceIp = VpnManager.normalizeRemoteAddress(connectedClient.remoteAddr);
if (sourceIp) {
nextSourceIps.set(clientId, sourceIp);
}
}
if (wireguardClientIds.size > 0 && typeof (this.vpnServer as any).listWgPeers === 'function') {
try {
const wgPeers = await this.vpnServer.listWgPeers();
const endpointByPublicKey = new Map<string, string>();
for (const peer of wgPeers) {
const endpointIp = VpnManager.normalizeRemoteAddress(peer.endpoint);
if (peer.publicKey && endpointIp) {
endpointByPublicKey.set(peer.publicKey, endpointIp);
}
}
for (const client of this.clients.values()) {
if (nextSourceIps.has(client.clientId)) continue;
if (!wireguardClientIds.has(client.clientId)) continue;
if (!client.wgPublicKey) continue;
const endpointIp = endpointByPublicKey.get(client.wgPublicKey);
if (endpointIp) {
nextSourceIps.set(client.clientId, endpointIp);
}
}
} catch (err) {
logger.log('warn', `VPN: Failed to refresh WireGuard peer endpoints: ${(err as Error).message}`);
}
}
if (this.sameSourceIpMap(this.clientSourceIps, nextSourceIps)) {
return false;
}
this.clientSourceIps = nextSourceIps;
if (notifyOnChange) {
this.config.onClientSourceIpsChanged?.();
}
return true;
} catch (err) {
logger.log('warn', `VPN: Failed to refresh client source IPs: ${(err as Error).message}`);
return false;
} finally {
this.clientSourceIpRefreshInFlight = false;
}
}
public static normalizeRemoteAddress(remoteAddress?: string): string | undefined {
const remoteAddressString = remoteAddress?.trim();
if (!remoteAddressString) return undefined;
if (remoteAddressString.startsWith('[')) {
const closingBracketIndex = remoteAddressString.indexOf(']');
if (closingBracketIndex > 0) {
const bracketedIp = remoteAddressString.slice(1, closingBracketIndex);
return plugins.net.isIP(bracketedIp) ? bracketedIp : undefined;
}
}
if (plugins.net.isIP(remoteAddressString)) {
return remoteAddressString;
}
const lastColonIndex = remoteAddressString.lastIndexOf(':');
if (lastColonIndex > -1 && remoteAddressString.indexOf(':') === lastColonIndex) {
const host = remoteAddressString.slice(0, lastColonIndex);
if (plugins.net.isIP(host)) {
return host;
}
}
return undefined;
}
/**
* Get telemetry for a specific client.
*/
@@ -533,10 +660,15 @@ export class VpnManager {
private async rewriteWireGuardAllowedIPs(
wireguardConfig: string,
targetProfileIds: string[],
clientId?: string,
): Promise<string> {
if (!this.config.getClientAllowedIPs) return wireguardConfig;
const allowedIPs = await this.config.getClientAllowedIPs(targetProfileIds);
const allowedIPs = await this.config.getClientAllowedIPs(
targetProfileIds,
clientId,
clientId ? this.getClientSourceIp(clientId) : undefined,
);
const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`;
@@ -587,6 +719,31 @@ export class VpnManager {
}
}
private startClientSourceIpPolling(): void {
this.stopClientSourceIpPolling();
const pollIntervalMs = Math.max(1000, this.config.clientSourceIpPollIntervalMs ?? 10_000);
this.clientSourceIpPollTimer = setInterval(() => {
void this.refreshClientSourceIps().catch((err) => {
logger.log('warn', `VPN: Client source IP polling failed: ${err?.message || err}`);
});
}, pollIntervalMs);
this.clientSourceIpPollTimer.unref?.();
}
private stopClientSourceIpPolling(): void {
if (!this.clientSourceIpPollTimer) return;
clearInterval(this.clientSourceIpPollTimer);
this.clientSourceIpPollTimer = undefined;
}
private sameSourceIpMap(left: Map<string, string>, right: Map<string, string>): boolean {
if (left.size !== right.size) return false;
for (const [clientId, sourceIp] of left) {
if (right.get(clientId) !== sourceIp) return false;
}
return true;
}
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
return this.resolvedForwardingMode
?? this.forwardingModeOverride
+12
View File
@@ -159,6 +159,17 @@ export interface IDomainActivity {
requestsLastMinute?: number;
}
export interface IAsnActivity {
asn: number;
organization: string;
country: string | null;
activeConnections: number;
ipCount: number;
bytesInPerSecond: number;
bytesOutPerSecond: number;
sampleIps: string[];
}
export interface INetworkMetrics {
totalBandwidth: {
in: number;
@@ -186,6 +197,7 @@ export interface INetworkMetrics {
out: number;
};
}>;
topASNs: IAsnActivity[];
domainActivity: IDomainActivity[];
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number;
+2
View File
@@ -23,6 +23,8 @@ export interface ITargetProfile {
targets?: ITargetProfileTarget[];
/** Route references by stored route ID. Legacy route names are normalized when unique. */
routeRefs?: string[];
/** Also allow routes whose source security would allow the VPN client's real connecting IP. */
allowRoutesByClientSourceIp?: boolean;
createdAt: number;
updatedAt: number;
createdBy: string;
+4
View File
@@ -45,6 +45,10 @@ export interface IVpnConnectedClient {
bytesSent: number;
bytesReceived: number;
transport: string;
/** Real client IP:port reported by the VPN transport, when available. */
remoteAddr?: string;
/** Parsed real client IP reported by the VPN transport, when available. */
sourceIp?: string;
}
/**
@@ -89,6 +89,8 @@ export interface IReq_ListIpIntelligence extends plugins.typedrequestInterfaces.
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
ipAddresses?: string[];
limit?: number;
};
response: {
records: IIpIntelligenceRecord[];
+1
View File
@@ -190,6 +190,7 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
requestsTotal: number;
backends?: statsInterfaces.IBackendInfo[];
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
topASNs: statsInterfaces.IAsnActivity[];
domainActivity: statsInterfaces.IDomainActivity[];
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
@@ -57,6 +57,7 @@ export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
};
response: {
success: boolean;
@@ -82,6 +83,7 @@ export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
};
response: {
success: boolean;
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.32.1',
version: '13.36.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+90 -48
View File
@@ -55,6 +55,7 @@ export interface INetworkState {
totalBytes: { in: number; out: number };
topIPs: Array<{ ip: string; count: number }>;
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
topASNs: interfaces.data.IAsnActivity[];
throughputByIP: Array<{ ip: string; in: number; out: number }>;
ipIntelligence: interfaces.data.IIpIntelligenceRecord[];
domainActivity: interfaces.data.IDomainActivity[];
@@ -176,6 +177,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
totalBytes: { in: 0, out: 0 },
topIPs: [],
topIPsByBandwidth: [],
topASNs: [],
throughputByIP: [],
ipIntelligence: [],
domainActivity: [],
@@ -582,6 +584,52 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
};
});
const backgroundRefreshesInFlight = new Set<string>();
function runBackgroundRefresh(key: string, errorMessage: string, task: () => Promise<void>): void {
if (backgroundRefreshesInFlight.has(key)) return;
backgroundRefreshesInFlight.add(key);
void task()
.catch((error) => console.error(errorMessage, error))
.finally(() => backgroundRefreshesInFlight.delete(key));
}
function refreshNetworkIpIntelligence(identity: interfaces.data.IIdentity, ipAddresses: string[]): void {
const ips = [...new Set(ipAddresses.filter(Boolean))].slice(0, 100);
if (ips.length === 0) return;
runBackgroundRefresh('networkIpIntelligence', 'IP intelligence refresh failed:', async () => {
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const intelligenceResponse = await intelligenceRequest.fire({
identity,
ipAddresses: ips,
limit: Math.max(100, ips.length),
});
networkStatePart.setState({
...networkStatePart.getState()!,
ipIntelligence: intelligenceResponse.records || [],
});
});
}
function refreshSecurityIpIntelligence(identity: interfaces.data.IIdentity): void {
runBackgroundRefresh('securityIpIntelligence', 'Security IP intelligence refresh failed:', async () => {
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const intelligenceResponse = await intelligenceRequest.fire({
identity,
limit: 500,
});
securityPolicyStatePart.setState({
...securityPolicyStatePart.getState()!,
ipIntelligence: intelligenceResponse.records || [],
});
});
}
// Fetch Network Stats Action
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg): Promise<INetworkState> => {
const context = getActionContext();
@@ -594,18 +642,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
interfaces.requests.IReq_GetNetworkStats
>('/typedrequest', 'getNetworkStats');
const ipIntelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const [networkStatsResponse, ipIntelligenceResponse] = await Promise.all([
networkStatsRequest.fire({
identity: context.identity,
}),
ipIntelligenceRequest.fire({
identity: context.identity,
}),
]);
const networkStatsResponse = await networkStatsRequest.fire({
identity: context.identity,
});
// Use the connections data for the connection list
// and network stats for throughput and IP analytics
@@ -637,6 +676,12 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
};
});
refreshNetworkIpIntelligence(context.identity, [
...Object.keys(connectionsByIP),
...(networkStatsResponse.topIPs || []).map((item) => item.ip),
...(networkStatsResponse.topIPsByBandwidth || []).map((item) => item.ip),
]);
return {
connections,
connectionsByIP,
@@ -646,8 +691,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
: { in: 0, out: 0 },
topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
topASNs: networkStatsResponse.topASNs || [],
throughputByIP: networkStatsResponse.throughputByIP || [],
ipIntelligence: ipIntelligenceResponse.records || [],
ipIntelligence: currentState.ipIntelligence,
domainActivity: networkStatsResponse.domainActivity || [],
throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
@@ -683,9 +729,6 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListSecurityBlockRules
>('/typedrequest', 'listSecurityBlockRules');
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const compiledPolicyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetCompiledSecurityPolicy
>('/typedrequest', 'getCompiledSecurityPolicy');
@@ -693,16 +736,17 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
interfaces.requests.IReq_ListSecurityPolicyAudit
>('/typedrequest', 'listSecurityPolicyAudit');
const [rulesResponse, intelligenceResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
const [rulesResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
rulesRequest.fire({ identity: context.identity }),
intelligenceRequest.fire({ identity: context.identity }),
compiledPolicyRequest.fire({ identity: context.identity }),
auditRequest.fire({ identity: context.identity, limit: 100 }),
]);
refreshSecurityIpIntelligence(context.identity);
return {
rules: rulesResponse.rules || [],
ipIntelligence: intelligenceResponse.records || [],
ipIntelligence: currentState.ipIntelligence,
compiledPolicy: compiledPolicyResponse.policy,
auditEvents: auditResponse.events || [],
isLoading: false,
@@ -835,7 +879,15 @@ export const refreshIpIntelligenceAction = securityPolicyStatePart.createAction<
if (!response.success) {
return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' };
}
return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
const refreshedState = await actionContext!.dispatch(fetchSecurityPolicyAction, null);
if (!response.record) return refreshedState;
return {
...refreshedState,
ipIntelligence: [
response.record,
...refreshedState.ipIntelligence.filter((record) => record.ipAddress !== response.record!.ipAddress),
],
};
} catch (error: unknown) {
return {
...currentState,
@@ -1520,6 +1572,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
domains?: string[];
targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
@@ -1533,6 +1586,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
});
if (!response.success) {
return {
@@ -1556,6 +1610,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
domains?: string[];
targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
@@ -1570,6 +1625,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
});
if (!response.success) {
return {
@@ -3099,6 +3155,7 @@ async function dispatchCombinedRefreshActionInner() {
bwIn: e.bandwidth?.in || 0,
bwOut: e.bandwidth?.out || 0,
})),
topASNs: network.topASNs || [],
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
domainActivity: network.domainActivity || [],
throughputHistory: network.throughputHistory || [],
@@ -3112,53 +3169,38 @@ async function dispatchCombinedRefreshActionInner() {
error: null,
});
try {
const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListIpIntelligence
>('/typedrequest', 'listIpIntelligence');
const intelligenceResponse = await intelligenceRequest.fire({ identity: context.identity });
networkStatePart.setState({
...networkStatePart.getState()!,
ipIntelligence: intelligenceResponse.records || [],
});
} catch (error) {
console.error('IP intelligence refresh failed:', error);
}
refreshNetworkIpIntelligence(context.identity, [
...network.connectionDetails.map((conn) => conn.remoteAddress),
...network.topEndpoints.map((endpoint) => endpoint.endpoint),
...(network.topEndpointsByBandwidth || []).map((endpoint) => endpoint.endpoint),
]);
}
if (currentView === 'security') {
try {
runBackgroundRefresh('securityPolicy', 'Security policy refresh failed:', async () => {
await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null);
} catch (error) {
console.error('Security policy refresh failed:', error);
}
});
}
// Refresh certificate data if on Domains > Certificates subview
if (currentView === 'domains' && currentSubview === 'certificates') {
try {
runBackgroundRefresh('certificates', 'Certificate refresh failed:', async () => {
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
} catch (error) {
console.error('Certificate refresh failed:', error);
}
});
}
// Refresh remote ingress data if on the Network → Remote Ingress subview
if (currentView === 'network' && currentSubview === 'remoteingress') {
try {
runBackgroundRefresh('remoteIngress', 'Remote ingress refresh failed:', async () => {
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
} catch (error) {
console.error('Remote ingress refresh failed:', error);
}
});
}
// Refresh VPN data if on the Network → VPN subview
if (currentView === 'network' && currentSubview === 'vpn') {
try {
runBackgroundRefresh('vpn', 'VPN refresh failed:', async () => {
await vpnStatePart.dispatchAction(fetchVpnAction, null);
} catch (error) {
console.error('VPN refresh failed:', error);
}
});
}
} catch (error) {
console.error('Combined refresh failed:', error);
@@ -308,6 +308,9 @@ export class OpsViewNetworkActivity extends DeesElement {
<!-- Top IPs by Connection Count -->
${this.renderTopIPs()}
<!-- Top ASNs by Connection Count -->
${this.renderTopASNs()}
<!-- Top IPs by Bandwidth -->
${this.renderTopIPsByBandwidth()}
@@ -450,6 +453,28 @@ export class OpsViewNetworkActivity extends DeesElement {
];
}
private getAsnDataActions() {
return [
{
name: 'Block ASN',
iconName: 'lucide:radio-tower',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
await this.createBlockRuleDialog('asn', String(actionData.item.asn), 'Blocked ASN from Network Activity');
},
},
{
name: 'Block Organization',
iconName: 'lucide:building-2',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.organization),
actionFunc: async (actionData: any) => {
await this.createBlockRuleDialog('organization', actionData.item.organization, 'Blocked organization from Network Activity');
},
},
];
}
private calculateThroughput(): { in: number; out: number } {
// Use real throughput data from network state
return {
@@ -619,6 +644,40 @@ export class OpsViewNetworkActivity extends DeesElement {
`;
}
private renderTopASNs(): TemplateResult {
if (!this.networkState.topASNs || this.networkState.topASNs.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.topASNs}
.rowKey=${'asn'}
.highlightUpdates=${'flash'}
.displayFunction=${(asnData: appstate.INetworkState['topASNs'][number]) => {
return {
'ASN': `AS${asnData.asn}`,
'Organization': this.formatOptional(asnData.organization),
'Connections': asnData.activeConnections,
'IPs': asnData.ipCount,
'Bandwidth In': this.formatBitsPerSecond(asnData.bytesInPerSecond),
'Bandwidth Out': this.formatBitsPerSecond(asnData.bytesOutPerSecond),
'Total Bandwidth': this.formatBitsPerSecond(asnData.bytesInPerSecond + asnData.bytesOutPerSecond),
'Country': this.formatOptional(asnData.country),
'Sample IPs': asnData.sampleIps.join(', '),
};
}}
.dataActions=${this.getAsnDataActions()}
heading1="Top Connected ASNs"
heading2="Organizations causing the most active connections across observed IPs"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ASN"
></dees-table>
`;
}
private renderTopIPsByBandwidth(): TemplateResult {
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
return html``;
@@ -97,6 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
: '-',
'Source-Policy Route Grants': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No',
Created: new Date(profile.createdAt).toLocaleDateString(),
})}
.dataActions=${[
@@ -223,6 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${false}></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
@@ -258,6 +260,7 @@ export class OpsViewTargetProfiles extends DeesElement {
domains: domains.length > 0 ? domains : undefined,
targets: targets.length > 0 ? targets : undefined,
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
});
modalArg.destroy();
},
@@ -284,6 +287,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
@@ -319,6 +323,7 @@ export class OpsViewTargetProfiles extends DeesElement {
domains,
targets,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
});
modalArg.destroy();
},
@@ -389,6 +394,10 @@ export class OpsViewTargetProfiles extends DeesElement {
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Client Source IP Routes</div>
<div style="font-size: 14px; margin-top: 4px;">${profile.allowRoutesByClientSourceIp ? 'Enabled' : 'Disabled'}</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>
+2
View File
@@ -339,6 +339,7 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml,
'Routing': routingHtml,
'VPN IP': client.assignedIp || '-',
'Source IP': conn?.sourceIp || '-',
'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(),
@@ -487,6 +488,7 @@ export class OpsViewVpn extends DeesElement {
${conn ? html`
<div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div>
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
<div class="infoItem"><span class="infoLabel">Source IP</span><span class="infoValue">${conn.sourceIp || '-'}</span></div>
` : ''}
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>