Compare commits

...

2 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
12 changed files with 209 additions and 6 deletions
+15
View File
@@ -3,6 +3,21 @@
## 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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.35.0",
"version": "13.36.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -269,6 +269,7 @@ tap.test('MetricsManager queues IP intelligence without awaiting enrichment', as
},
securityPolicyManager: {
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
listIpIntelligence: async () => [],
},
} as any);
@@ -279,4 +280,50 @@ tap.test('MetricsManager queues IP intelligence without awaiting enrichment', as
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();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.35.0',
version: '13.36.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+65 -3
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,10 +727,15 @@ export class MetricsManager {
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
this.dcRouter.securityPolicyManager?.queueObservedIps([
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();
@@ -872,6 +879,7 @@ export class MetricsManager {
throughputRate,
topIPs,
topIPsByBandwidth,
topASNs,
totalDataTransferred,
throughputHistory,
throughputByIP,
@@ -885,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 {
@@ -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: [],
+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,
+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;
+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;
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.35.0',
version: '13.36.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+4
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: [],
@@ -689,6 +691,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
: { in: 0, out: 0 },
topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
topASNs: networkStatsResponse.topASNs || [],
throughputByIP: networkStatsResponse.throughputByIP || [],
ipIntelligence: currentState.ipIntelligence,
domainActivity: networkStatsResponse.domainActivity || [],
@@ -3152,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 || [],
@@ -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``;