Compare commits

...

5 Commits

Author SHA1 Message Date
jkunz 1c3aa89f8d v13.21.0
Docker (tags) / security (push) Failing after 10s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-25 20:37:28 +00:00
jkunz b3751abd17 feat(monitoring): improve network activity metrics with live domain request rates and backend identifiers 2026-04-25 20:37:28 +00:00
jkunz 97017ede98 chore(deps): update serve.zone interfaces 2026-04-25 14:01:26 +00:00
jkunz 4b928b038e v13.20.2
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-17 14:28:19 +00:00
jkunz a466b88408 fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates 2026-04-17 14:28:19 +00:00
23 changed files with 615 additions and 146 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## 2026-04-25 - 13.21.0 - feat(monitoring)
improve network activity metrics with live domain request rates and backend identifiers
- use SmartProxy per-domain live request rates to rank and attribute domain activity metrics, while retaining lifetime request totals as fallback data
- separate aggregate backend rows from protocol cache rows with stable ids so cached protocol entries no longer duplicate active backend connection counts
- expose frontend and backend protocol distributions plus aggregated connectionCount fields through ops and web network views
## 2026-04-17 - 13.20.2 - fix(vpn)
handle VPN forwarding mode downgrades and support runtime VPN config updates
- restart the VPN server back to socket mode when host-IP clients are removed while preserving explicit hybrid mode
- allow DcRouter to update VPN configuration at runtime and refresh route allow-list resolution without recreating the router
- improve VPN operations UI target profile rendering and loading behavior for create and edit flows
## 2026-04-17 - 13.20.1 - fix(docs) ## 2026-04-17 - 13.20.1 - fix(docs)
refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "13.20.1", "version": "13.21.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -54,7 +54,7 @@
"@push.rocks/smartnetwork": "^4.6.0", "@push.rocks/smartnetwork": "^4.6.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.7.4", "@push.rocks/smartproxy": "^27.8.0",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
@@ -63,7 +63,7 @@
"@push.rocks/smartvpn": "1.19.2", "@push.rocks/smartvpn": "1.19.2",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4", "@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.4.3",
"@serve.zone/remoteingress": "^4.15.3", "@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0", "@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
+10 -13
View File
@@ -81,8 +81,8 @@ importers:
specifier: ^4.2.3 specifier: ^4.2.3
version: 4.2.3 version: 4.2.3
'@push.rocks/smartproxy': '@push.rocks/smartproxy':
specifier: ^27.7.4 specifier: ^27.8.0
version: 27.7.4 version: 27.8.0
'@push.rocks/smartradius': '@push.rocks/smartradius':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@@ -108,8 +108,8 @@ importers:
specifier: ^2.12.4 specifier: ^2.12.4
version: 2.12.4(@tiptap/pm@2.27.2) version: 2.12.4(@tiptap/pm@2.27.2)
'@serve.zone/interfaces': '@serve.zone/interfaces':
specifier: ^5.3.0 specifier: ^5.4.3
version: 5.3.0 version: 5.4.3
'@serve.zone/remoteingress': '@serve.zone/remoteingress':
specifier: ^4.15.3 specifier: ^4.15.3
version: 4.15.3 version: 4.15.3
@@ -147,9 +147,6 @@ importers:
'@types/node': '@types/node':
specifier: ^25.6.0 specifier: ^25.6.0
version: 25.6.0 version: 25.6.0
typescript:
specifier: ^6.0.2
version: 6.0.2
packages: packages:
@@ -1287,8 +1284,8 @@ packages:
'@push.rocks/smartpromise@4.2.3': '@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@27.7.4': '@push.rocks/smartproxy@27.8.0':
resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==} resolution: {integrity: sha512-/+rfSAz9hRopuRRBwaI/VVtFTLNemnh9RIf0YAPRhrLCL4WGJXkjnpX4Zi6W1AAPDU2wlz7Zm0F6TO+nXLqP5w==}
'@push.rocks/smartpuppeteer@2.0.5': '@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1594,8 +1591,8 @@ packages:
'@serve.zone/catalog@2.12.4': '@serve.zone/catalog@2.12.4':
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==} resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
'@serve.zone/interfaces@5.3.0': '@serve.zone/interfaces@5.4.3':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} resolution: {integrity: sha512-9ijFhHoC7GYyyAUJbBoDYmcoCmIXTFPiD6fI3x68SWiC0xA+2LG0nOe14D32c1QN9X/3i2Ac5/1sUibfjHsIGg==}
'@serve.zone/remoteingress@4.15.3': '@serve.zone/remoteingress@4.15.3':
resolution: {integrity: sha512-kg/bmR+qcFRFuigTDr5Fao72cb7m/mSkI5APm7KZDKSUYTFuytNoj6KCIE0ICkc3Nh34y8oDwFJsS6oFo64AyQ==} resolution: {integrity: sha512-kg/bmR+qcFRFuigTDr5Fao72cb7m/mSkI5APm7KZDKSUYTFuytNoj6KCIE0ICkc3Nh34y8oDwFJsS6oFo64AyQ==}
@@ -6515,7 +6512,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {} '@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.7.4': '@push.rocks/smartproxy@27.8.0':
dependencies: dependencies:
'@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
@@ -6930,7 +6927,7 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@serve.zone/interfaces@5.3.0': '@serve.zone/interfaces@5.4.3':
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2 '@push.rocks/smartlog-interfaces': 3.0.2
+7 -1
View File
@@ -101,7 +101,13 @@ tap.test('should login as admin for email API tests', async () => {
password: 'admin', password: 'admin',
}); });
adminIdentity = response.identity; const responseIdentity = response.identity;
expect(responseIdentity).toBeDefined();
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
expect(adminIdentity.jwt).toBeTruthy(); expect(adminIdentity.jwt).toBeTruthy();
}); });
+9
View File
@@ -103,6 +103,9 @@ tap.test('ErrorHandler should properly handle and format errors', async () => {
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' }); }, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(PlatformError); expect(error).toBeInstanceOf(PlatformError);
if (!(error instanceof PlatformError)) {
throw error;
}
expect(error.code).toEqual('TEST_EXECUTION_ERROR'); expect(error.code).toEqual('TEST_EXECUTION_ERROR');
expect(error.context.operation).toEqual('testExecution'); expect(error.context.operation).toEqual('testExecution');
} }
@@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => {
} }
); );
} catch (error) { } catch (error) {
if (!(error instanceof Error)) {
throw error;
}
expect(error.message).toEqual('Critical error'); expect(error.message).toEqual('Critical error');
expect(attempts).toEqual(1); // Should only attempt once expect(attempts).toEqual(1); // Should only attempt once
} }
@@ -262,6 +268,9 @@ tap.test('Error handling can be combined with retry for robust operations', asyn
// Should not reach here // Should not reach here
expect(false).toEqual(true); expect(false).toEqual(true);
} catch (error) { } catch (error) {
if (!(error instanceof Error)) {
throw error;
}
expect(error.message).toContain('Flaky failure'); expect(error.message).toContain('Flaky failure');
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
} }
+22 -10
View File
@@ -29,14 +29,18 @@ tap.test('should login with admin credentials and receive JWT', async () => {
}); });
expect(response).toHaveProperty('identity'); expect(response).toHaveProperty('identity');
expect(response.identity).toHaveProperty('jwt'); const responseIdentity = response.identity;
expect(response.identity).toHaveProperty('userId'); if (!responseIdentity) {
expect(response.identity).toHaveProperty('name'); throw new Error('Expected admin login response to include identity');
expect(response.identity).toHaveProperty('expiresAt'); }
expect(response.identity).toHaveProperty('role'); expect(responseIdentity).toHaveProperty('jwt');
expect(response.identity.role).toEqual('admin'); expect(responseIdentity).toHaveProperty('userId');
expect(responseIdentity).toHaveProperty('name');
expect(responseIdentity).toHaveProperty('expiresAt');
expect(responseIdentity).toHaveProperty('role');
expect(responseIdentity.role).toEqual('admin');
identity = response.identity; identity = responseIdentity;
console.log('JWT:', identity.jwt); console.log('JWT:', identity.jwt);
}); });
@@ -53,7 +57,11 @@ tap.test('should verify valid JWT identity', async () => {
expect(response).toHaveProperty('valid'); expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue(); expect(response.valid).toBeTrue();
expect(response).toHaveProperty('identity'); expect(response).toHaveProperty('identity');
expect(response.identity.userId).toEqual(identity.userId); const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected verify response to include identity');
}
expect(responseIdentity.userId).toEqual(identity.userId);
}); });
tap.test('should reject invalid JWT', async () => { tap.test('should reject invalid JWT', async () => {
@@ -86,8 +94,12 @@ tap.test('should verify JWT matches identity data', async () => {
expect(response).toHaveProperty('valid'); expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue(); expect(response.valid).toBeTrue();
expect(response.identity.expiresAt).toEqual(identity.expiresAt); const responseIdentity = response.identity;
expect(response.identity.userId).toEqual(identity.userId); if (!responseIdentity) {
throw new Error('Expected verify response to include identity');
}
expect(responseIdentity.expiresAt).toEqual(identity.expiresAt);
expect(responseIdentity.userId).toEqual(identity.userId);
}); });
tap.test('should handle logout', async () => { tap.test('should handle logout', async () => {
+124 -2
View File
@@ -18,6 +18,9 @@ function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>; connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>; throughputByRoute: Map<string, { in: number; out: number }>;
domainRequestsByIP: Map<string, Map<string, number>>; domainRequestsByIP: Map<string, Map<string, number>>;
domainRequestRates?: Map<string, { perSecond: number; lastMinute: number }>;
backendMetrics?: Map<string, any>;
protocolCache?: any[];
requestsTotal?: number; requestsTotal?: number;
}) { }) {
return { return {
@@ -45,6 +48,7 @@ function createProxyMetrics(args: {
perSecond: () => 0, perSecond: () => 0,
perMinute: () => 0, perMinute: () => 0,
total: () => args.requestsTotal || 0, total: () => args.requestsTotal || 0,
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
}, },
totals: { totals: {
bytesIn: () => 0, bytesIn: () => 0,
@@ -52,10 +56,10 @@ function createProxyMetrics(args: {
connections: () => 0, connections: () => 0,
}, },
backends: { backends: {
byBackend: () => new Map<string, any>(), byBackend: () => args.backendMetrics || new Map<string, any>(),
protocols: () => new Map<string, string>(), protocols: () => new Map<string, string>(),
topByErrors: () => [], topByErrors: () => [],
detectedProtocols: () => [], detectedProtocols: () => args.protocolCache || [],
}, },
}; };
} }
@@ -117,4 +121,122 @@ tap.test('MetricsManager joins domain activity to id-keyed route metrics', async
expect(beta!.bytesOutPerSecond).toEqual(600); expect(beta!.bytesOutPerSecond).toEqual(600);
}); });
tap.test('MetricsManager prefers live domain request rates for current activity', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map([
['route-id-only', 10],
]),
throughputByRoute: new Map([
['route-id-only', { in: 1000, out: 1000 }],
]),
domainRequestsByIP: new Map([
['192.0.2.10', new Map([
['alpha.example.com', 1000],
['beta.example.com', 1],
])],
]),
domainRequestRates: new Map([
['alpha.example.com', { perSecond: 0, lastMinute: 0 }],
['beta.example.com', { perSecond: 5, lastMinute: 60 }],
]),
});
const smartProxy = {
getMetrics: () => proxyMetrics,
routeManager: {
getRoutes: () => [
{
id: 'route-id-only',
match: {
ports: [443],
domains: ['alpha.example.com', 'beta.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
},
},
],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
expect(alpha!.activeConnections).toEqual(0);
expect(alpha!.requestsPerSecond).toEqual(0);
expect(beta!.activeConnections).toEqual(10);
expect(beta!.requestsPerSecond).toEqual(5);
expect(beta!.bytesInPerSecond).toEqual(1000);
});
tap.test('MetricsManager does not duplicate backend active counts onto protocol cache rows', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
backendMetrics: new Map([
['192.0.2.1:443', {
protocol: 'h2',
activeConnections: 257,
totalConnections: 1000,
connectErrors: 1,
handshakeErrors: 2,
requestErrors: 3,
avgConnectTimeMs: 4,
poolHitRate: 0.9,
h2Failures: 5,
}],
]),
protocolCache: [
{
host: '192.0.2.1',
port: 443,
domain: 'alpha.example.com',
protocol: 'h2',
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
ageSecs: 1,
},
{
host: '192.0.2.1',
port: 443,
domain: 'beta.example.com',
protocol: 'h2',
h2Suppressed: false,
h3Suppressed: false,
h2CooldownRemainingSecs: null,
h3CooldownRemainingSecs: null,
h2ConsecutiveFailures: null,
h3ConsecutiveFailures: null,
h3Port: null,
ageSecs: 1,
},
],
});
const smartProxy = {
getMetrics: () => proxyMetrics,
routeManager: {
getRoutes: () => [],
},
};
const manager = new MetricsManager({ smartProxy } as any);
const stats = await manager.getNetworkStats();
const aggregate = stats.backends.find((item) => item.id === 'backend:192.0.2.1:443');
const cacheRows = stats.backends.filter((item) => item.id?.startsWith('cache:'));
expect(aggregate!.activeConnections).toEqual(257);
expect(cacheRows.length).toEqual(2);
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
});
export default tap.start(); export default tap.start();
+5 -1
View File
@@ -29,7 +29,11 @@ tap.test('should login as admin', async () => {
}); });
expect(response).toHaveProperty('identity'); expect(response).toHaveProperty('identity');
adminIdentity = response.identity; const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
}); });
tap.test('should respond to health status request', async () => { tap.test('should respond to health status request', async () => {
+5 -1
View File
@@ -29,7 +29,11 @@ tap.test('should login as admin', async () => {
}); });
expect(response).toHaveProperty('identity'); expect(response).toHaveProperty('identity');
adminIdentity = response.identity; const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
console.log('Admin logged in with JWT'); console.log('Admin logged in with JWT');
}); });
+5 -1
View File
@@ -35,7 +35,11 @@ tap.test('should login as admin', async () => {
}); });
expect(response).toHaveProperty('identity'); expect(response).toHaveProperty('identity');
adminIdentity = response.identity; const responseIdentity = response.identity;
if (!responseIdentity) {
throw new Error('Expected admin login response to include identity');
}
adminIdentity = responseIdentity;
}); });
// ============================================================================ // ============================================================================
+110
View File
@@ -0,0 +1,110 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
(manager as any).forwardingModeOverride = undefined;
(manager as any).vpnServer = { running: true };
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(true);
expect(stopCalls).toEqual(1);
expect(startCalls).toEqual(1);
expect((manager as any).resolvedForwardingMode).toEqual('socket');
});
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
const manager = new VpnManager({ forwardingMode: 'hybrid' });
let stopCalls = 0;
let startCalls = 0;
(manager as any).vpnServer = { running: true };
(manager as any).resolvedForwardingMode = 'hybrid';
(manager as any).clients = new Map([
['client-1', { useHostIp: false }],
]);
(manager as any).stop = async () => {
stopCalls++;
};
(manager as any).start = async () => {
startCalls++;
};
const restarted = await (manager as any).reconcileForwardingMode();
expect(restarted).toEqual(false);
expect(stopCalls).toEqual(0);
expect(startCalls).toEqual(0);
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
});
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
const dcRouter = new DcRouter({
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
vpnConfig: { enabled: false },
});
let stopCalls = 0;
let setupCalls = 0;
let applyCalls = 0;
const resolverValues: Array<unknown> = [];
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
(dcRouter as any).routeConfigManager = {
setVpnClientIpsResolver: (resolver: unknown) => {
resolverValues.push(resolver);
},
applyRoutes: async () => {
applyCalls++;
},
};
(dcRouter as any).setupVpnServer = async () => {
setupCalls++;
dcRouter.vpnManager = {
stop: async () => {
stopCalls++;
},
} as any;
};
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
expect(stopCalls).toEqual(1);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(0);
expect(typeof resolverValues.at(-1)).toEqual('function');
await dcRouter.updateVpnConfig({ enabled: false });
expect(stopCalls).toEqual(2);
expect(setupCalls).toEqual(1);
expect(applyCalls).toEqual(1);
expect(resolverValues.at(-1)).toBeUndefined();
expect(dcRouter.vpnManager).toBeUndefined();
});
export default tap.start()
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.20.1', version: '13.21.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }
+51 -14
View File
@@ -26,6 +26,7 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js'; import { DnsManager } from './dns/manager.dns.js';
@@ -565,20 +566,7 @@ export class DcRouter {
this.routeConfigManager = new RouteConfigManager( this.routeConfigManager = new RouteConfigManager(
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
this.options.vpnConfig?.enabled this.createVpnRouteAllowListResolver(),
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
if (!this.vpnManager || !this.targetProfileManager) {
// VPN not ready yet — deny all until re-apply after VPN starts
return [];
}
return this.targetProfileManager.getMatchingClientIps(
route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
);
}
: undefined,
this.referenceResolver, this.referenceResolver,
// Sync routes to RemoteIngressManager whenever routes change, // Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary // then push updated derived ports to the Rust hub binary
@@ -2292,6 +2280,32 @@ export class DcRouter {
/** /**
* Set up VPN server for VPN-based route access control. * Set up VPN server for VPN-based route access control.
*/ */
private createVpnRouteAllowListResolver(): ((
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string,
) => TIpAllowEntry[]) | undefined {
if (!this.options.vpnConfig?.enabled) {
return undefined;
}
return (
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string,
) => {
if (!this.vpnManager || !this.targetProfileManager) {
// VPN not ready yet — deny all until re-apply after VPN starts.
return [];
}
return this.targetProfileManager.getMatchingClientIps(
route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
);
};
}
private async setupVpnServer(): Promise<void> { private async setupVpnServer(): Promise<void> {
if (!this.options.vpnConfig?.enabled) { if (!this.options.vpnConfig?.enabled) {
return; return;
@@ -2441,6 +2455,29 @@ export class DcRouter {
logger.log('info', 'RADIUS configuration updated'); logger.log('info', 'RADIUS configuration updated');
} }
/**
* Update VPN configuration at runtime.
*/
public async updateVpnConfig(config: IDcRouterOptions['vpnConfig']): Promise<void> {
if (this.vpnManager) {
await this.vpnManager.stop();
this.vpnManager = undefined;
}
this.options.vpnConfig = config;
this.vpnDomainIpCache.clear();
this.warnedWildcardVpnDomains.clear();
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver());
if (this.options.vpnConfig?.enabled) {
await this.setupVpnServer();
} else {
await this.routeConfigManager?.applyRoutes();
}
logger.log('info', 'VPN configuration updated');
}
} }
// Re-export email server types for convenience // Re-export email server types for convenience
@@ -73,6 +73,12 @@ export class RouteConfigManager {
return this.routes.get(id); return this.routes.get(id);
} }
public setVpnClientIpsResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
): void {
this.getVpnClientIpsForRoute = resolver;
}
/** /**
* Load persisted routes, seed serializable config/email/dns routes, * Load persisted routes, seed serializable config/email/dns routes,
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy. * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
+51 -20
View File
@@ -560,7 +560,9 @@ export class MetricsManager {
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [] as Array<any>, backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>, domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number; requestsPerSecond?: number; requestsLastMinute?: number }>,
frontendProtocols: null,
backendProtocols: null,
}; };
} }
@@ -592,6 +594,7 @@ export class MetricsManager {
// Get HTTP request rates // Get HTTP request rates
const requestsPerSecond = proxyMetrics.requests.perSecond(); const requestsPerSecond = proxyMetrics.requests.perSecond();
const requestsTotal = proxyMetrics.requests.total(); const requestsTotal = proxyMetrics.requests.total();
const domainRequestRates = proxyMetrics.requests.byDomain();
// Get frontend/backend protocol distribution // Get frontend/backend protocol distribution
const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null; const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null;
@@ -619,10 +622,8 @@ export class MetricsManager {
const seenCacheKeys = new Set<string>(); const seenCacheKeys = new Set<string>();
for (const [key, bm] of backendMetrics) { for (const [key, bm] of backendMetrics) {
const cacheEntries = cacheByBackend.get(key);
if (!cacheEntries || cacheEntries.length === 0) {
// No protocol cache entry — emit one row with backend metrics only
backends.push({ backends.push({
id: `backend:${key}`,
backend: key, backend: key,
domain: null, domain: null,
protocol: bm.protocol, protocol: bm.protocol,
@@ -643,23 +644,26 @@ export class MetricsManager {
h3Port: null, h3Port: null,
cacheAgeSecs: null, cacheAgeSecs: null,
}); });
} else {
// One row per domain, each enriched with the shared backend metrics const cacheEntries = cacheByBackend.get(key);
if (cacheEntries && cacheEntries.length > 0) {
// Protocol cache rows are domain-scoped metadata, not live backend connections.
for (const cache of cacheEntries) { for (const cache of cacheEntries) {
const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`; const compositeKey = `${cache.host}:${cache.port}:${cache.domain ?? ''}`;
seenCacheKeys.add(compositeKey); seenCacheKeys.add(compositeKey);
backends.push({ backends.push({
id: `cache:${compositeKey}`,
backend: key, backend: key,
domain: cache.domain ?? null, domain: cache.domain ?? null,
protocol: cache.protocol ?? bm.protocol, protocol: cache.protocol ?? bm.protocol,
activeConnections: bm.activeConnections, activeConnections: 0,
totalConnections: bm.totalConnections, totalConnections: 0,
connectErrors: bm.connectErrors, connectErrors: 0,
handshakeErrors: bm.handshakeErrors, handshakeErrors: 0,
requestErrors: bm.requestErrors, requestErrors: 0,
avgConnectTimeMs: Math.round(bm.avgConnectTimeMs * 10) / 10, avgConnectTimeMs: 0,
poolHitRate: Math.round(bm.poolHitRate * 1000) / 1000, poolHitRate: 0,
h2Failures: bm.h2Failures, h2Failures: 0,
h2Suppressed: cache.h2Suppressed, h2Suppressed: cache.h2Suppressed,
h3Suppressed: cache.h3Suppressed, h3Suppressed: cache.h3Suppressed,
h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs, h2CooldownRemainingSecs: cache.h2CooldownRemainingSecs,
@@ -678,6 +682,7 @@ export class MetricsManager {
const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`; const compositeKey = `${entry.host}:${entry.port}:${entry.domain ?? ''}`;
if (!seenCacheKeys.has(compositeKey)) { if (!seenCacheKeys.has(compositeKey)) {
backends.push({ backends.push({
id: `cache:${compositeKey}`,
backend: `${entry.host}:${entry.port}`, backend: `${entry.host}:${entry.port}`,
domain: entry.domain, domain: entry.domain,
protocol: entry.protocol, protocol: entry.protocol,
@@ -750,6 +755,9 @@ export class MetricsManager {
// Resolve wildcards using domains seen in request metrics // Resolve wildcards using domains seen in request metrics
const allKnownDomains = new Set<string>(domainRequestTotals.keys()); const allKnownDomains = new Set<string>(domainRequestTotals.keys());
for (const domain of domainRequestRates.keys()) {
allKnownDomains.add(domain);
}
for (const entry of protocolCache) { for (const entry of protocolCache) {
if (entry.domain) allKnownDomains.add(entry.domain); if (entry.domain) allKnownDomains.add(entry.domain);
} }
@@ -775,11 +783,20 @@ export class MetricsManager {
} }
} }
// For each route, compute the total request count across all its resolved domains const hasLiveDomainRates = domainRequestRates.size > 0;
// so we can distribute throughput/connections proportionally const getDomainWeight = (domain: string): number => {
const liveRate = domainRequestRates.get(domain);
return hasLiveDomainRates
? (liveRate?.lastMinute ?? 0)
: (domainRequestTotals.get(domain) || 0);
};
// For each route, compute the total activity weight across all resolved domains
// so we can distribute route-level throughput/connections. Prefer live domain
// request rates from SmartProxy 27.8+, falling back to lifetime counters.
const routeTotalRequests = new Map<string, number>(); const routeTotalRequests = new Map<string, number>();
for (const [domain, routeKeys] of domainToRoutes) { for (const [domain, routeKeys] of domainToRoutes) {
const reqs = domainRequestTotals.get(domain) || 0; const reqs = getDomainWeight(domain);
for (const routeKey of routeKeys) { for (const routeKey of routeKeys) {
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs); routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
} }
@@ -792,10 +809,13 @@ export class MetricsManager {
bytesOutPerSec: number; bytesOutPerSec: number;
routeCount: number; routeCount: number;
requestCount: number; requestCount: number;
requestsPerSecond: number;
requestsLastMinute: number;
}>(); }>();
for (const [domain, routeKeys] of domainToRoutes) { for (const [domain, routeKeys] of domainToRoutes) {
const domainReqs = domainRequestTotals.get(domain) || 0; const domainReqs = getDomainWeight(domain);
const requestRate = domainRequestRates.get(domain);
let totalConns = 0; let totalConns = 0;
let totalIn = 0; let totalIn = 0;
let totalOut = 0; let totalOut = 0;
@@ -816,7 +836,9 @@ export class MetricsManager {
bytesInPerSec: totalIn, bytesInPerSec: totalIn,
bytesOutPerSec: totalOut, bytesOutPerSec: totalOut,
routeCount: routeKeys.length, routeCount: routeKeys.length,
requestCount: domainReqs, requestCount: domainRequestTotals.get(domain) || 0,
requestsPerSecond: requestRate?.perSecond ?? 0,
requestsLastMinute: requestRate?.lastMinute ?? 0,
}); });
} }
@@ -828,8 +850,17 @@ export class MetricsManager {
activeConnections: data.activeConnections, activeConnections: data.activeConnections,
routeCount: data.routeCount, routeCount: data.routeCount,
requestCount: data.requestCount, requestCount: data.requestCount,
requestsPerSecond: data.requestsPerSecond,
requestsLastMinute: data.requestsLastMinute,
})) }))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond)); .sort((a, b) => {
if (hasLiveDomainRates) {
return (b.requestsPerSecond - a.requestsPerSecond) ||
(b.requestsLastMinute - a.requestsLastMinute) ||
((b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
}
return (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond);
});
return { return {
connectionsByIP, connectionsByIP,
+10 -4
View File
@@ -50,19 +50,21 @@ export class SecurityHandler {
localAddress: conn.destination.ip, localAddress: conn.destination.ip,
startTime: conn.startTime, startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any, protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any, state: conn.status === 'active' ? 'connected' : conn.status as any,
bytesReceived: (conn as any)._throughputIn || 0, bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: (conn as any)._throughputOut || 0, bytesSent: (conn as any)._throughputOut || 0,
connectionCount: conn.bytesTransferred || 1,
})); }));
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
const summary = { const summary = {
total: connectionInfos.length, total: totalConnections,
byProtocol: connectionInfos.reduce((acc, conn) => { byProtocol: connectionInfos.reduce((acc, conn) => {
acc[conn.protocol] = (acc[conn.protocol] || 0) + 1; acc[conn.protocol] = (acc[conn.protocol] || 0) + (conn.connectionCount || 1);
return acc; return acc;
}, {} as { [protocol: string]: number }), }, {} as { [protocol: string]: number }),
byState: connectionInfos.reduce((acc, conn) => { byState: connectionInfos.reduce((acc, conn) => {
acc[conn.state] = (acc[conn.state] || 0) + 1; acc[conn.state] = (acc[conn.state] || 0) + (conn.connectionCount || 1);
return acc; return acc;
}, {} as { [state: string]: number }), }, {} as { [state: string]: number }),
}; };
@@ -104,6 +106,8 @@ export class SecurityHandler {
requestsPerSecond: networkStats.requestsPerSecond || 0, requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0, requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [], backends: networkStats.backends || [],
frontendProtocols: networkStats.frontendProtocols || null,
backendProtocols: networkStats.backendProtocols || null,
}; };
} }
@@ -120,6 +124,8 @@ export class SecurityHandler {
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [], backends: [],
frontendProtocols: null,
backendProtocols: null,
}; };
} }
) )
+1
View File
@@ -302,6 +302,7 @@ export class StatsHandler {
startTime: 0, startTime: 0,
bytesIn: tp?.in || 0, bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0, bytesOut: tp?.out || 0,
connectionCount: count,
}); });
} }
+65 -14
View File
@@ -112,14 +112,11 @@ export class VpnManager {
const subnet = this.getSubnet(); const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820; const wgListenPort = this.config.wgListenPort ?? 51820;
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
if (anyClientUsesHostIp && configuredMode === 'socket') {
configuredMode = 'hybrid';
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)'); logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
} }
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode; const forwardingMode = desiredForwardingMode;
const isBridge = forwardingMode === 'bridge'; const isBridge = forwardingMode === 'bridge';
this.resolvedForwardingMode = forwardingMode; this.resolvedForwardingMode = forwardingMode;
this.forwardingModeOverride = undefined; this.forwardingModeOverride = undefined;
@@ -218,7 +215,7 @@ export class VpnManager {
throw new Error('VPN server not running'); throw new Error('VPN server not running');
} }
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true); await this.ensureForwardingModeForNextClient(opts.useHostIp === true);
const doc = new VpnClientDoc(); const doc = new VpnClientDoc();
doc.clientId = opts.clientId; doc.clientId = opts.clientId;
@@ -298,6 +295,7 @@ export class VpnManager {
if (doc) { if (doc) {
await doc.delete(); await doc.delete();
} }
await this.reconcileForwardingMode();
this.config.onClientChanged?.(); this.config.onClientChanged?.();
} }
@@ -368,9 +366,11 @@ export class VpnManager {
await this.persistClient(client); await this.persistClient(client);
if (this.vpnServer) { if (this.vpnServer) {
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true); const restarted = await this.reconcileForwardingMode();
if (!restarted) {
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client)); await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
} }
}
this.config.onClientChanged?.(); this.config.onClientChanged?.();
} }
@@ -563,6 +563,28 @@ export class VpnManager {
?? 'socket'; ?? 'socket';
} }
private hasHostIpClients(extraHostIpClient = false): boolean {
if (extraHostIpClient) {
return true;
}
for (const client of this.clients.values()) {
if (client.useHostIp) {
return true;
}
}
return false;
}
private getDesiredForwardingMode(hasHostIpClients = this.hasHostIpClients()): 'socket' | 'bridge' | 'hybrid' {
const configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
if (configuredMode !== 'socket') {
return configuredMode;
}
return hasHostIpClients ? 'hybrid' : 'socket';
}
private getDefaultDestinationPolicy( private getDefaultDestinationPolicy(
forwardingMode: 'socket' | 'bridge' | 'hybrid', forwardingMode: 'socket' | 'bridge' | 'hybrid',
useHostIp = false, useHostIp = false,
@@ -633,16 +655,45 @@ export class VpnManager {
}; };
} }
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> { private async restartWithForwardingMode(
if (!useHostIp || !this.vpnServer) return; forwardingMode: 'socket' | 'bridge' | 'hybrid',
if (this.getResolvedForwardingMode() !== 'socket') return; reason: string,
): Promise<void> {
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client'); logger.log('info', `VPN: Restarting server in ${forwardingMode} mode ${reason}`);
this.forwardingModeOverride = 'hybrid'; this.forwardingModeOverride = forwardingMode;
await this.stop(); await this.stop();
await this.start(); await this.start();
} }
private async ensureForwardingModeForNextClient(useHostIp: boolean): Promise<void> {
if (!this.vpnServer) return;
const desiredForwardingMode = this.getDesiredForwardingMode(this.hasHostIpClients(useHostIp));
if (desiredForwardingMode === this.getResolvedForwardingMode()) {
return;
}
await this.restartWithForwardingMode(desiredForwardingMode, 'to support a host-IP client');
}
private async reconcileForwardingMode(): Promise<boolean> {
if (!this.vpnServer) {
return false;
}
const desiredForwardingMode = this.getDesiredForwardingMode();
const currentForwardingMode = this.getResolvedForwardingMode();
if (desiredForwardingMode === currentForwardingMode) {
return false;
}
const reason = desiredForwardingMode === 'socket'
? 'because no host-IP clients remain'
: 'to support host-IP clients';
await this.restartWithForwardingMode(desiredForwardingMode, reason);
return true;
}
private async persistClient(client: VpnClientDoc): Promise<void> { private async persistClient(client: VpnClientDoc): Promise<void> {
await client.save(); await client.save();
} }
+10
View File
@@ -119,6 +119,8 @@ export interface IConnectionInfo {
state: 'connecting' | 'connected' | 'authenticated' | 'transmitting' | 'closing'; state: 'connecting' | 'connected' | 'authenticated' | 'transmitting' | 'closing';
bytesReceived: number; bytesReceived: number;
bytesSent: number; bytesSent: number;
/** Present when the row is an aggregate, e.g. one row per remote IP. */
connectionCount?: number;
} }
export interface IQueueStatus { export interface IQueueStatus {
@@ -149,7 +151,12 @@ export interface IDomainActivity {
bytesOutPerSecond: number; bytesOutPerSecond: number;
activeConnections: number; activeConnections: number;
routeCount: number; routeCount: number;
/** Lifetime request count when available from SmartProxy. */
requestCount: number; requestCount: number;
/** Live HTTP request rate when SmartProxy exposes per-domain rates. */
requestsPerSecond?: number;
/** HTTP requests over the last minute when SmartProxy exposes per-domain rates. */
requestsLastMinute?: number;
} }
export interface INetworkMetrics { export interface INetworkMetrics {
@@ -208,9 +215,12 @@ export interface IConnectionDetails {
startTime: number; startTime: number;
bytesIn: number; bytesIn: number;
bytesOut: number; bytesOut: number;
/** Present when the row is an aggregate, e.g. one row per remote IP. */
connectionCount?: number;
} }
export interface IBackendInfo { export interface IBackendInfo {
id?: string;
backend: string; backend: string;
domain: string | null; domain: string | null;
protocol: string; protocol: string;
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.20.1', version: '13.21.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }
+23 -18
View File
@@ -512,15 +512,6 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
if (!context.identity) return currentState; if (!context.identity) return currentState;
try { try {
// Fetch active connections using the existing endpoint
const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetActiveConnections
>('/typedrequest', 'getActiveConnections');
const connectionsResponse = await connectionsRequest.fire({
identity: context.identity,
});
// Get network stats for throughput and IP data // Get network stats for throughput and IP data
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkStats interfaces.requests.IReq_GetNetworkStats
@@ -533,22 +524,35 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
// Use the connections data for the connection list // Use the connections data for the connection list
// and network stats for throughput and IP analytics // and network stats for throughput and IP analytics
const connectionsByIP: { [ip: string]: number } = {}; const connectionsByIP: { [ip: string]: number } = {};
const throughputByIP = new Map<string, { in: number; out: number }>();
for (const item of networkStatsResponse.throughputByIP || []) {
throughputByIP.set(item.ip, { in: item.in, out: item.out });
}
// Build connectionsByIP from network stats if available // Build connectionsByIP from network stats if available
if (networkStatsResponse.connectionsByIP && Array.isArray(networkStatsResponse.connectionsByIP)) { if (networkStatsResponse.connectionsByIP && Array.isArray(networkStatsResponse.connectionsByIP)) {
networkStatsResponse.connectionsByIP.forEach((item: { ip: string; count: number }) => { networkStatsResponse.connectionsByIP.forEach((item: { ip: string; count: number }) => {
connectionsByIP[item.ip] = item.count; connectionsByIP[item.ip] = item.count;
}); });
} else {
// Fallback: calculate from connections
connectionsResponse.connections.forEach(conn => {
const ip = conn.remoteAddress;
connectionsByIP[ip] = (connectionsByIP[ip] || 0) + 1;
});
} }
const connections: interfaces.data.IConnectionInfo[] = Object.entries(connectionsByIP).map(([ip, count]) => {
const tp = throughputByIP.get(ip);
return { return {
connections: connectionsResponse.connections, id: `ip-${ip}`,
remoteAddress: ip,
localAddress: 'server',
startTime: 0,
protocol: 'https',
state: 'connected',
bytesReceived: tp?.in || 0,
bytesSent: tp?.out || 0,
connectionCount: count,
};
});
return {
connections,
connectionsByIP, connectionsByIP,
throughputRate: networkStatsResponse.throughputRate || { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: networkStatsResponse.throughputRate || { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
totalBytes: networkStatsResponse.totalDataTransferred totalBytes: networkStatsResponse.totalDataTransferred
@@ -2589,7 +2593,7 @@ async function dispatchCombinedRefreshActionInner() {
email: true, email: true,
dns: true, dns: true,
security: true, security: true,
network: currentView === 'network', // Only fetch network if on network view network: currentView === 'network' && currentSubview === 'activity',
radius: true, radius: true,
vpn: true, vpn: true,
}, },
@@ -2617,7 +2621,7 @@ async function dispatchCombinedRefreshActionInner() {
// Build connectionsByIP from connectionDetails (now populated with real per-IP data) // Build connectionsByIP from connectionDetails (now populated with real per-IP data)
network.connectionDetails.forEach(conn => { network.connectionDetails.forEach(conn => {
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1; connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + (conn.connectionCount || 1);
}); });
// Build connections from connectionDetails (real per-IP aggregates) // Build connections from connectionDetails (real per-IP aggregates)
@@ -2630,6 +2634,7 @@ async function dispatchCombinedRefreshActionInner() {
state: conn.state as any, state: conn.state as any,
bytesReceived: conn.bytesIn, bytesReceived: conn.bytesIn,
bytesSent: conn.bytesOut, bytesSent: conn.bytesOut,
connectionCount: conn.connectionCount,
})); }));
networkStatePart.setState({ networkStatePart.setState({
@@ -79,7 +79,6 @@ export class OpsViewNetworkActivity extends DeesElement {
// Subscribe and track unsubscribe functions // Subscribe and track unsubscribe functions
const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => { const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
this.statsState = state; this.statsState = state;
this.updateNetworkData();
}); });
this.rxSubscriptions.push(statsUnsubscribe); this.rxSubscriptions.push(statsUnsubscribe);
@@ -560,6 +559,8 @@ export class OpsViewNetworkActivity extends DeesElement {
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond), 'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
'Transferred / min': this.formatBytes(totalBytesPerMin), 'Transferred / min': this.formatBytes(totalBytesPerMin),
'Connections': item.activeConnections, 'Connections': item.activeConnections,
'Req/s': item.requestsPerSecond != null ? item.requestsPerSecond.toFixed(1) : '-',
'Req/min': item.requestsLastMinute != null ? item.requestsLastMinute.toFixed(0) : '-',
'Requests': item.requestCount?.toLocaleString() ?? '0', 'Requests': item.requestCount?.toLocaleString() ?? '0',
'Routes': item.routeCount, 'Routes': item.routeCount,
}; };
@@ -583,7 +584,7 @@ export class OpsViewNetworkActivity extends DeesElement {
return html` return html`
<dees-table <dees-table
.data=${backends} .data=${backends}
.rowKey=${'backend'} .rowKey=${'id'}
.highlightUpdates=${'flash'} .highlightUpdates=${'flash'}
.displayFunction=${(item: interfaces.data.IBackendInfo) => { .displayFunction=${(item: interfaces.data.IBackendInfo) => {
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors; const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
@@ -707,6 +708,9 @@ export class OpsViewNetworkActivity extends DeesElement {
} }
const throughput = this.calculateThroughput(); const throughput = this.calculateThroughput();
if (this.networkState.lastUpdated && now - this.networkState.lastUpdated > 3000) {
return;
}
// Convert to Mbps (bytes * 8 / 1,000,000) // Convert to Mbps (bytes * 8 / 1,000,000)
const throughputInMbps = (throughput.in * 8) / 1000000; const throughputInMbps = (throughput.in * 8) / 1000000;
+50 -14
View File
@@ -49,19 +49,28 @@ export class OpsViewVpn extends DeesElement {
@state() @state()
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!; accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
@state()
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
constructor() { constructor() {
super(); super();
const sub = appstate.vpnStatePart.select().subscribe((newState) => { const sub = appstate.vpnStatePart.select().subscribe((newState) => {
this.vpnState = newState; this.vpnState = newState;
}); });
this.rxSubscriptions.push(sub); this.rxSubscriptions.push(sub);
const targetProfilesSub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
this.targetProfilesState = newState;
});
this.rxSubscriptions.push(targetProfilesSub);
} }
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null); await Promise.all([
// Ensure target profiles are loaded for autocomplete candidates appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null),
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null); appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null),
]);
} }
public static styles = [ public static styles = [
@@ -330,13 +339,7 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml, 'Status': statusHtml,
'Routing': routingHtml, 'Routing': routingHtml,
'VPN IP': client.assignedIp || '-', 'VPN IP': client.assignedIp || '-',
'Target Profiles': client.targetProfileIds?.length 'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
? html`${client.targetProfileIds.map(id => {
const profileState = appstate.targetProfilesStatePart.getState();
const profile = profileState?.profiles.find(p => p.id === id);
return html`<span class="tagBadge">${profile?.name || id}</span>`;
})}`
: '-',
'Description': client.description || '-', 'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(), 'Created': new Date(client.createdAt).toLocaleDateString(),
}; };
@@ -347,6 +350,7 @@ export class OpsViewVpn extends DeesElement {
iconName: 'lucide:plus', iconName: 'lucide:plus',
type: ['header'], type: ['header'],
actionFunc: async () => { actionFunc: async () => {
await this.ensureTargetProfilesLoaded();
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const profileCandidates = this.getTargetProfileCandidates(); const profileCandidates = this.getTargetProfileCandidates();
const createModal = await DeesModal.createAndShow({ const createModal = await DeesModal.createAndShow({
@@ -647,6 +651,7 @@ export class OpsViewVpn extends DeesElement {
type: ['contextmenu', 'inRow'], type: ['contextmenu', 'inRow'],
actionFunc: async (actionData: any) => { actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient; const client = actionData.item as interfaces.data.IVpnClient;
await this.ensureTargetProfilesLoaded();
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? ''; const currentDescription = client.description ?? '';
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || []; const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
@@ -810,12 +815,28 @@ export class OpsViewVpn extends DeesElement {
`; `;
} }
private async ensureTargetProfilesLoaded(): Promise<void> {
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
}
private renderTargetProfileBadges(ids?: string[]): TemplateResult | string {
const labels = this.resolveProfileIdsToLabels(ids, {
pendingLabel: 'Loading profile...',
missingLabel: (id) => `Unknown profile (${id})`,
});
if (!labels?.length) {
return '-';
}
return html`${labels.map((label) => html`<span class="tagBadge">${label}</span>`)}`;
}
/** /**
* Build stable profile labels for list inputs. * Build stable profile labels for list inputs.
*/ */
private getTargetProfileChoices() { private getTargetProfileChoices() {
const profileState = appstate.targetProfilesStatePart.getState(); const profiles = this.targetProfilesState.profiles || [];
const profiles = profileState?.profiles || [];
const nameCounts = new Map<string, number>(); const nameCounts = new Map<string, number>();
for (const profile of profiles) { for (const profile of profiles) {
@@ -837,12 +858,27 @@ export class OpsViewVpn extends DeesElement {
/** /**
* Convert profile IDs to form labels (for populating edit form values). * Convert profile IDs to form labels (for populating edit form values).
*/ */
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined { private resolveProfileIdsToLabels(
ids?: string[],
options: {
pendingLabel?: string;
missingLabel?: (id: string) => string;
} = {},
): string[] | undefined {
if (!ids?.length) return undefined; if (!ids?.length) return undefined;
const choices = this.getTargetProfileChoices(); const choices = this.getTargetProfileChoices();
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label])); const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
return ids.map((id) => { return ids.map((id) => {
return labelsById.get(id) || id; const label = labelsById.get(id);
if (label) {
return label;
}
if (this.targetProfilesState.lastUpdated === 0 && !this.targetProfilesState.error) {
return options.pendingLabel || 'Loading profile...';
}
return options.missingLabel?.(id) || id;
}); });
} }