@serve.zone/dcrouter 17.1.4 → 17.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +559 -559
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +9 -3
- package/dist_ts/db/documents/classes.ip-intelligence.doc.d.ts +10 -0
- package/dist_ts/db/documents/classes.ip-intelligence.doc.js +63 -1
- package/dist_ts/dns/classes.dns-server-runtime.d.ts +1 -0
- package/dist_ts/dns/classes.dns-server-runtime.js +8 -4
- package/dist_ts/dns/manager.dns.js +23 -6
- package/dist_ts/email/classes.email-domain.manager.js +27 -15
- package/dist_ts/email/classes.mail-dns-sync.d.ts +19 -2
- package/dist_ts/email/classes.mail-dns-sync.js +158 -32
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/email/mail-dns-status.d.ts +5 -0
- package/dist_ts/email/mail-dns-status.js +31 -0
- package/dist_ts/monitoring/classes.metricscache.d.ts +2 -0
- package/dist_ts/monitoring/classes.metricscache.js +28 -6
- package/dist_ts/monitoring/classes.metricsmanager.d.ts +10 -0
- package/dist_ts/monitoring/classes.metricsmanager.js +99 -31
- package/dist_ts/opsserver/handlers/security.handler.js +24 -6
- package/dist_ts/opsserver/handlers/stats.handler.js +2 -3
- package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -2
- package/dist_ts/security/classes.security-policy-manager.d.ts +17 -0
- package/dist_ts/security/classes.security-policy-manager.js +171 -26
- package/dist_ts_interfaces/requests/stats.d.ts +7 -0
- package/dist_ts_migrations/index.js +395 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/network.js +11 -2
- package/dist_ts_web/appstate/ui.js +1 -8
- package/package.json +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +11 -2
- package/ts/db/documents/classes.ip-intelligence.doc.ts +75 -0
- package/ts/dns/classes.dns-server-runtime.ts +9 -3
- package/ts/dns/manager.dns.ts +22 -5
- package/ts/email/classes.email-domain.manager.ts +23 -16
- package/ts/email/classes.mail-dns-sync.ts +172 -38
- package/ts/email/index.ts +1 -0
- package/ts/email/mail-dns-status.ts +35 -0
- package/ts/monitoring/classes.metricscache.ts +36 -8
- package/ts/monitoring/classes.metricsmanager.ts +113 -32
- package/ts/opsserver/handlers/security.handler.ts +35 -6
- package/ts/opsserver/handlers/stats.handler.ts +1 -2
- package/ts/remoteingress/classes.hub-lifecycle.ts +3 -1
- package/ts/security/classes.security-policy-manager.ts +193 -26
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/network.ts +12 -1
- package/ts_web/appstate/ui.ts +0 -9
|
@@ -5,11 +5,20 @@ import { SecurityLogger, SecurityEventType } from '../security/classes.securityl
|
|
|
5
5
|
import { logger } from '../logger.js';
|
|
6
6
|
import type { IAsnActivity } from '../../ts_interfaces/data/stats.js';
|
|
7
7
|
|
|
8
|
+
const NETWORK_STATS_CACHE_TTL_MS = 3_000;
|
|
9
|
+
const ASN_INTELLIGENCE_CACHE_TTL_MS = 60_000;
|
|
10
|
+
const ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS = 3_000;
|
|
11
|
+
const OBSERVED_IP_SAMPLE_INTERVAL_MS = 60_000;
|
|
12
|
+
const MAX_ASN_CANDIDATE_IPS = 500;
|
|
13
|
+
const MAX_OBSERVED_IP_SAMPLE_SIZE = 20;
|
|
14
|
+
const MAX_SNAPSHOT_LIMIT = 1_000;
|
|
15
|
+
|
|
8
16
|
export class MetricsManager {
|
|
9
17
|
private metricsLogger: plugins.smartlog.Smartlog;
|
|
10
18
|
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
|
11
19
|
private dcRouter: DcRouter;
|
|
12
20
|
private resetInterval?: NodeJS.Timeout;
|
|
21
|
+
private observedIpSamplerInterval?: NodeJS.Timeout;
|
|
13
22
|
private metricsCache: MetricsCache;
|
|
14
23
|
|
|
15
24
|
// Constants
|
|
@@ -69,7 +78,7 @@ export class MetricsManager {
|
|
|
69
78
|
}
|
|
70
79
|
});
|
|
71
80
|
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter');
|
|
72
|
-
//
|
|
81
|
+
// Individual metrics select an appropriate TTL where their collection cost differs.
|
|
73
82
|
this.metricsCache = new MetricsCache(500);
|
|
74
83
|
}
|
|
75
84
|
|
|
@@ -118,6 +127,11 @@ export class MetricsManager {
|
|
|
118
127
|
// Prune old time-series buckets every minute (don't wait for lazy query)
|
|
119
128
|
this.pruneOldBuckets();
|
|
120
129
|
}, 60000); // Check every minute
|
|
130
|
+
|
|
131
|
+
this.observedIpSamplerInterval = setInterval(() => {
|
|
132
|
+
this.sampleObservedIps();
|
|
133
|
+
}, OBSERVED_IP_SAMPLE_INTERVAL_MS);
|
|
134
|
+
this.observedIpSamplerInterval.unref();
|
|
121
135
|
|
|
122
136
|
logger.log('info', 'MetricsManager started');
|
|
123
137
|
}
|
|
@@ -128,6 +142,10 @@ export class MetricsManager {
|
|
|
128
142
|
clearInterval(this.resetInterval);
|
|
129
143
|
this.resetInterval = undefined;
|
|
130
144
|
}
|
|
145
|
+
if (this.observedIpSamplerInterval) {
|
|
146
|
+
clearInterval(this.observedIpSamplerInterval);
|
|
147
|
+
this.observedIpSamplerInterval = undefined;
|
|
148
|
+
}
|
|
131
149
|
|
|
132
150
|
this.smartMetrics.stop();
|
|
133
151
|
|
|
@@ -295,13 +313,35 @@ export class MetricsManager {
|
|
|
295
313
|
public async getActiveConnectionSnapshots(
|
|
296
314
|
options: plugins.smartproxy.IActiveConnectionSnapshotOptions = {},
|
|
297
315
|
): Promise<plugins.smartproxy.IActiveConnectionSnapshot[]> {
|
|
298
|
-
const
|
|
316
|
+
const normalizedOptions = {
|
|
317
|
+
...options,
|
|
318
|
+
limit: Math.min(Math.max(options.limit ?? MAX_SNAPSHOT_LIMIT, 1), MAX_SNAPSHOT_LIMIT),
|
|
319
|
+
};
|
|
320
|
+
const afterId = (normalizedOptions as typeof normalizedOptions & { afterId?: string }).afterId ?? '';
|
|
321
|
+
const cacheKey = `activeConnectionSnapshots:${normalizedOptions.limit}:${normalizedOptions.routeId ?? ''}:${afterId}`;
|
|
299
322
|
return await this.metricsCache.get<plugins.smartproxy.IActiveConnectionSnapshot[]>(cacheKey, async () => {
|
|
300
323
|
if (!this.dcRouter.smartProxy) {
|
|
301
324
|
return [];
|
|
302
325
|
}
|
|
303
|
-
return this.dcRouter.smartProxy.getActiveConnectionSnapshots(
|
|
304
|
-
},
|
|
326
|
+
return this.dcRouter.smartProxy.getActiveConnectionSnapshots(normalizedOptions);
|
|
327
|
+
}, ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
public async getActiveConnectionSnapshotPage(
|
|
331
|
+
options: plugins.smartproxy.IActiveConnectionSnapshotOptions = {},
|
|
332
|
+
): Promise<plugins.smartproxy.IActiveConnectionSnapshotPage> {
|
|
333
|
+
const normalizedOptions = {
|
|
334
|
+
...options,
|
|
335
|
+
limit: Math.min(Math.max(options.limit ?? MAX_SNAPSHOT_LIMIT, 1), MAX_SNAPSHOT_LIMIT),
|
|
336
|
+
};
|
|
337
|
+
const afterId = normalizedOptions.afterId ?? '';
|
|
338
|
+
const cacheKey = `activeConnectionSnapshotPage:${normalizedOptions.limit}:${normalizedOptions.routeId ?? ''}:${afterId}`;
|
|
339
|
+
return await this.metricsCache.get<plugins.smartproxy.IActiveConnectionSnapshotPage>(cacheKey, async () => {
|
|
340
|
+
if (!this.dcRouter.smartProxy) {
|
|
341
|
+
return { snapshots: [], hasMore: false, nextCursor: null };
|
|
342
|
+
}
|
|
343
|
+
return this.dcRouter.smartProxy.getActiveConnectionSnapshotPage(normalizedOptions);
|
|
344
|
+
}, ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS);
|
|
305
345
|
}
|
|
306
346
|
|
|
307
347
|
// Get connection info from SmartProxy
|
|
@@ -560,10 +600,59 @@ export class MetricsManager {
|
|
|
560
600
|
this.securityMetrics.incidents.shift();
|
|
561
601
|
}
|
|
562
602
|
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Feed IP intelligence from maintained data-plane counters, independently of
|
|
606
|
+
* dashboard reads. Startup order is intentionally tolerated: until both
|
|
607
|
+
* SmartProxy and SecurityPolicyManager exist, the sample is a no-op.
|
|
608
|
+
*/
|
|
609
|
+
private sampleObservedIps(): void {
|
|
610
|
+
const smartProxy = this.dcRouter.smartProxy;
|
|
611
|
+
const securityPolicyManager = this.dcRouter.securityPolicyManager;
|
|
612
|
+
if (!smartProxy || !securityPolicyManager) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const proxyMetrics = smartProxy.getMetrics();
|
|
618
|
+
const connectionsByIP = proxyMetrics.connections.byIP();
|
|
619
|
+
const throughputByIP = proxyMetrics.throughput.byIP();
|
|
620
|
+
const candidates = new Map<string, { connections: number; throughput: number }>();
|
|
621
|
+
|
|
622
|
+
for (const [ip, connections] of connectionsByIP) {
|
|
623
|
+
candidates.set(ip, { connections, throughput: 0 });
|
|
624
|
+
}
|
|
625
|
+
for (const [ip, throughput] of throughputByIP) {
|
|
626
|
+
const candidate = candidates.get(ip) || { connections: 0, throughput: 0 };
|
|
627
|
+
candidate.throughput = throughput.in + throughput.out;
|
|
628
|
+
candidates.set(ip, candidate);
|
|
629
|
+
}
|
|
630
|
+
for (const ip of proxyMetrics.connections.domainRequestsByIP().keys()) {
|
|
631
|
+
if (!candidates.has(ip)) {
|
|
632
|
+
candidates.set(ip, { connections: 0, throughput: 0 });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const observedIps = [...candidates.entries()]
|
|
637
|
+
.filter(([ip]) => Boolean(ip))
|
|
638
|
+
.sort((a, b) => (
|
|
639
|
+
b[1].connections - a[1].connections
|
|
640
|
+
|| b[1].throughput - a[1].throughput
|
|
641
|
+
|| a[0].localeCompare(b[0])
|
|
642
|
+
))
|
|
643
|
+
.slice(0, MAX_OBSERVED_IP_SAMPLE_SIZE)
|
|
644
|
+
.map(([ip]) => ip);
|
|
645
|
+
|
|
646
|
+
if (observedIps.length > 0) {
|
|
647
|
+
securityPolicyManager.queueObservedIps(observedIps);
|
|
648
|
+
}
|
|
649
|
+
} catch (error: unknown) {
|
|
650
|
+
logger.log('warn', `Observed-IP metrics sample failed: ${(error as Error).message}`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
563
653
|
|
|
564
654
|
// Get network metrics from SmartProxy
|
|
565
655
|
public async getNetworkStats() {
|
|
566
|
-
// Use shorter cache TTL for network stats to ensure real-time updates
|
|
567
656
|
return this.metricsCache.get('networkStats', async () => {
|
|
568
657
|
const smartProxy = this.dcRouter.smartProxy;
|
|
569
658
|
const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null;
|
|
@@ -571,6 +660,7 @@ export class MetricsManager {
|
|
|
571
660
|
if (!proxyMetrics) {
|
|
572
661
|
return {
|
|
573
662
|
connectionsByIP: new Map<string, number>(),
|
|
663
|
+
activeConnections: 0,
|
|
574
664
|
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
|
575
665
|
topIPs: [] as Array<{ ip: string; count: number }>,
|
|
576
666
|
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
|
|
@@ -587,21 +677,9 @@ export class MetricsManager {
|
|
|
587
677
|
};
|
|
588
678
|
}
|
|
589
679
|
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
const connectionsByRoute = new Map<string, number>();
|
|
594
|
-
const activeConnectionsByDomain = new Map<string, number>();
|
|
595
|
-
|
|
596
|
-
for (const snapshot of activeConnectionSnapshots) {
|
|
597
|
-
connectionsByIP.set(snapshot.sourceIp, (connectionsByIP.get(snapshot.sourceIp) || 0) + 1);
|
|
598
|
-
if (snapshot.routeId) {
|
|
599
|
-
connectionsByRoute.set(snapshot.routeId, (connectionsByRoute.get(snapshot.routeId) || 0) + 1);
|
|
600
|
-
}
|
|
601
|
-
if (snapshot.domain) {
|
|
602
|
-
activeConnectionsByDomain.set(snapshot.domain, (activeConnectionsByDomain.get(snapshot.domain) || 0) + 1);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
680
|
+
const connectionsByIP = proxyMetrics.connections.byIP();
|
|
681
|
+
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
|
682
|
+
const activeConnections = proxyMetrics.connections.active();
|
|
605
683
|
|
|
606
684
|
const instantThroughput = proxyMetrics.throughput.instant();
|
|
607
685
|
|
|
@@ -769,7 +847,6 @@ export class MetricsManager {
|
|
|
769
847
|
...topIPs.map((item) => item.ip),
|
|
770
848
|
...topIPsByBandwidth.map((item) => item.ip),
|
|
771
849
|
])];
|
|
772
|
-
this.dcRouter.securityPolicyManager?.queueObservedIps(observedIps);
|
|
773
850
|
|
|
774
851
|
const topASNs = await this.buildTopASNs(observedIps, allIPData);
|
|
775
852
|
|
|
@@ -808,10 +885,6 @@ export class MetricsManager {
|
|
|
808
885
|
for (const entry of protocolCache) {
|
|
809
886
|
if (entry.domain) allKnownDomains.add(entry.domain);
|
|
810
887
|
}
|
|
811
|
-
for (const snapshot of activeConnectionSnapshots) {
|
|
812
|
-
if (snapshot.domain) allKnownDomains.add(snapshot.domain);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
888
|
// Build reverse map: concrete domain → canonical route key(s)
|
|
816
889
|
const domainToRoutes = new Map<string, string[]>();
|
|
817
890
|
for (const [routeKey, domains] of routeDomains) {
|
|
@@ -882,7 +955,7 @@ export class MetricsManager {
|
|
|
882
955
|
}
|
|
883
956
|
|
|
884
957
|
domainAgg.set(domain, {
|
|
885
|
-
activeConnections:
|
|
958
|
+
activeConnections: Math.round(totalConns),
|
|
886
959
|
bytesInPerSec: totalIn,
|
|
887
960
|
bytesOutPerSec: totalOut,
|
|
888
961
|
routeCount: routeKeys.length,
|
|
@@ -914,6 +987,7 @@ export class MetricsManager {
|
|
|
914
987
|
|
|
915
988
|
return {
|
|
916
989
|
connectionsByIP,
|
|
990
|
+
activeConnections,
|
|
917
991
|
throughputRate,
|
|
918
992
|
topIPs,
|
|
919
993
|
topIPsByBandwidth,
|
|
@@ -928,7 +1002,7 @@ export class MetricsManager {
|
|
|
928
1002
|
backendProtocols,
|
|
929
1003
|
domainActivity,
|
|
930
1004
|
};
|
|
931
|
-
},
|
|
1005
|
+
}, NETWORK_STATS_CACHE_TTL_MS);
|
|
932
1006
|
}
|
|
933
1007
|
|
|
934
1008
|
private async buildTopASNs(
|
|
@@ -936,14 +1010,21 @@ export class MetricsManager {
|
|
|
936
1010
|
allIPData: Map<string, { count: number; bwIn: number; bwOut: number }>,
|
|
937
1011
|
): Promise<IAsnActivity[]> {
|
|
938
1012
|
const manager = this.dcRouter.securityPolicyManager;
|
|
939
|
-
|
|
1013
|
+
const candidateIps = [...new Set(observedIps.filter(Boolean))]
|
|
1014
|
+
.sort()
|
|
1015
|
+
.slice(0, MAX_ASN_CANDIDATE_IPS);
|
|
1016
|
+
if (!manager || candidateIps.length === 0) {
|
|
940
1017
|
return [];
|
|
941
1018
|
}
|
|
942
1019
|
|
|
943
|
-
const intelligenceRecords = await
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1020
|
+
const intelligenceRecords = await this.metricsCache.get(
|
|
1021
|
+
'networkAsnIntelligence',
|
|
1022
|
+
() => manager.listIpIntelligence({
|
|
1023
|
+
ipAddresses: candidateIps,
|
|
1024
|
+
limit: Math.max(100, candidateIps.length),
|
|
1025
|
+
}),
|
|
1026
|
+
ASN_INTELLIGENCE_CACHE_TTL_MS,
|
|
1027
|
+
);
|
|
947
1028
|
const asnActivity = new Map<number, IAsnActivity>();
|
|
948
1029
|
|
|
949
1030
|
for (const record of intelligenceRecords) {
|
|
@@ -45,7 +45,13 @@ export class SecurityHandler {
|
|
|
45
45
|
'getActiveConnections',
|
|
46
46
|
async (dataArg, toolsArg) => {
|
|
47
47
|
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
|
|
48
|
-
const
|
|
48
|
+
const page = await this.getActiveConnections(
|
|
49
|
+
dataArg.protocol,
|
|
50
|
+
dataArg.state,
|
|
51
|
+
dataArg.limit,
|
|
52
|
+
dataArg.cursor,
|
|
53
|
+
);
|
|
54
|
+
const connectionInfos = page.connections;
|
|
49
55
|
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
|
|
50
56
|
|
|
51
57
|
const summary = {
|
|
@@ -63,6 +69,7 @@ export class SecurityHandler {
|
|
|
63
69
|
return {
|
|
64
70
|
connections: connectionInfos,
|
|
65
71
|
summary,
|
|
72
|
+
pagination: page.pagination,
|
|
66
73
|
};
|
|
67
74
|
}
|
|
68
75
|
)
|
|
@@ -349,14 +356,27 @@ export class SecurityHandler {
|
|
|
349
356
|
|
|
350
357
|
private async getActiveConnections(
|
|
351
358
|
protocol?: 'http' | 'https' | 'smtp' | 'smtps',
|
|
352
|
-
state?: string
|
|
353
|
-
|
|
359
|
+
state?: string,
|
|
360
|
+
requestedLimit?: number,
|
|
361
|
+
cursor?: string,
|
|
362
|
+
): Promise<{
|
|
363
|
+
connections: interfaces.data.IConnectionInfo[];
|
|
364
|
+
pagination: { limit: number; hasMore: boolean; nextCursor?: string };
|
|
365
|
+
}> {
|
|
366
|
+
const limit = Math.min(Math.max(Math.floor(requestedLimit ?? 250), 1), 1_000);
|
|
354
367
|
const metricsManager = this.opsServerRef.dcRouterRef.metricsManager;
|
|
355
368
|
if (!metricsManager) {
|
|
356
|
-
return
|
|
369
|
+
return {
|
|
370
|
+
connections: [],
|
|
371
|
+
pagination: { limit, hasMore: false },
|
|
372
|
+
};
|
|
357
373
|
}
|
|
358
374
|
|
|
359
|
-
const
|
|
375
|
+
const page = await metricsManager.getActiveConnectionSnapshotPage({
|
|
376
|
+
limit,
|
|
377
|
+
afterId: cursor,
|
|
378
|
+
});
|
|
379
|
+
const snapshots = page.snapshots;
|
|
360
380
|
const connections = snapshots.map((snapshot): interfaces.data.IConnectionInfo => ({
|
|
361
381
|
id: String(snapshot.id),
|
|
362
382
|
remoteAddress: snapshot.sourcePort === null
|
|
@@ -372,7 +392,7 @@ export class SecurityHandler {
|
|
|
372
392
|
bytesSent: snapshot.bytesOut,
|
|
373
393
|
}));
|
|
374
394
|
|
|
375
|
-
|
|
395
|
+
const filteredConnections = connections.filter((connection) => {
|
|
376
396
|
if (protocol && connection.protocol !== protocol) {
|
|
377
397
|
return false;
|
|
378
398
|
}
|
|
@@ -381,6 +401,15 @@ export class SecurityHandler {
|
|
|
381
401
|
}
|
|
382
402
|
return true;
|
|
383
403
|
});
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
connections: filteredConnections,
|
|
407
|
+
pagination: {
|
|
408
|
+
limit,
|
|
409
|
+
hasMore: page.hasMore,
|
|
410
|
+
nextCursor: page.nextCursor ?? undefined,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
384
413
|
}
|
|
385
414
|
|
|
386
415
|
private mapSnapshotProtocol(
|
|
@@ -288,7 +288,6 @@ export class StatsHandler {
|
|
|
288
288
|
promises.push(
|
|
289
289
|
(async () => {
|
|
290
290
|
const stats = await this.opsServerRef.dcRouterRef.metricsManager!.getNetworkStats();
|
|
291
|
-
const serverStats = await this.collectServerStats();
|
|
292
291
|
|
|
293
292
|
// Build per-IP bandwidth lookup from throughputByIP
|
|
294
293
|
const ipBandwidth = new Map<string, { in: number; out: number }>();
|
|
@@ -322,7 +321,7 @@ export class StatsHandler {
|
|
|
322
321
|
in: stats.totalDataTransferred.bytesIn,
|
|
323
322
|
out: stats.totalDataTransferred.bytesOut,
|
|
324
323
|
},
|
|
325
|
-
activeConnections:
|
|
324
|
+
activeConnections: stats.activeConnections,
|
|
326
325
|
connectionDetails,
|
|
327
326
|
topEndpoints: stats.topIPs.map(ip => ({
|
|
328
327
|
endpoint: ip.ip,
|
|
@@ -223,7 +223,9 @@ export class RemoteIngressHubLifecycle {
|
|
|
223
223
|
tls: tlsConfig,
|
|
224
224
|
performance: manager.getHubPerformanceConfig(),
|
|
225
225
|
});
|
|
226
|
-
tunnelManager.setOnTopologyChanged((reason) =>
|
|
226
|
+
tunnelManager.setOnTopologyChanged((reason) => (
|
|
227
|
+
this.dcRouterRef.mailDnsSync.requestEdgeEligibilityCheck(reason)
|
|
228
|
+
));
|
|
227
229
|
try {
|
|
228
230
|
await tunnelManager.start();
|
|
229
231
|
} catch (err) {
|
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
|
|
13
13
|
export interface ISecurityPolicyManagerOptions {
|
|
14
14
|
intelligenceRefreshMs?: number;
|
|
15
|
+
retentionSweepIntervalMs?: number;
|
|
16
|
+
now?: () => number;
|
|
15
17
|
onPolicyChanged?: () => void | Promise<void>;
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -24,12 +26,24 @@ const OBSERVED_IP_BATCH_LIMIT = 20;
|
|
|
24
26
|
const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
|
|
25
27
|
const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
|
|
26
28
|
|
|
29
|
+
export const IP_INTELLIGENCE_LIST_LIMIT = 500;
|
|
30
|
+
export const IP_INTELLIGENCE_RETENTION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
31
|
+
export const IP_INTELLIGENCE_RETENTION_HIGH_WATER = 10_000;
|
|
32
|
+
export const IP_INTELLIGENCE_RETENTION_LOW_WATER = 9_000;
|
|
33
|
+
export const IP_INTELLIGENCE_RETENTION_BATCH_SIZE = 500;
|
|
34
|
+
export const IP_INTELLIGENCE_RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
35
|
+
|
|
27
36
|
export class SecurityPolicyManager {
|
|
28
37
|
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
|
|
29
38
|
cacheTtl: 24 * 60 * 60 * 1000,
|
|
30
39
|
ipIntelligenceTimeout: 5_000,
|
|
31
40
|
});
|
|
32
41
|
private readonly intelligenceRefreshMs: number;
|
|
42
|
+
private readonly retentionSweepIntervalMs: number;
|
|
43
|
+
private readonly now: () => number;
|
|
44
|
+
private retentionSweepTimer?: ReturnType<typeof setInterval>;
|
|
45
|
+
private retentionSweepPromise?: Promise<void>;
|
|
46
|
+
private retentionMutationTail: Promise<void> = Promise.resolve();
|
|
33
47
|
private readonly inFlightObservations = new Map<string, Promise<void>>();
|
|
34
48
|
private readonly queuedObservations = new Set<string>();
|
|
35
49
|
private readonly observationQueue: string[] = [];
|
|
@@ -41,18 +55,34 @@ export class SecurityPolicyManager {
|
|
|
41
55
|
|
|
42
56
|
constructor(options: ISecurityPolicyManagerOptions = {}) {
|
|
43
57
|
this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000;
|
|
58
|
+
this.retentionSweepIntervalMs = options.retentionSweepIntervalMs
|
|
59
|
+
?? IP_INTELLIGENCE_RETENTION_SWEEP_INTERVAL_MS;
|
|
60
|
+
this.now = options.now ?? Date.now;
|
|
44
61
|
this.onPolicyChanged = options.onPolicyChanged;
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
public async start(): Promise<void> {
|
|
65
|
+
this.isStopping = false;
|
|
66
|
+
await this.requestRetentionSweep();
|
|
67
|
+
this.retentionSweepTimer = setInterval(() => {
|
|
68
|
+
void this.requestRetentionSweep();
|
|
69
|
+
}, this.retentionSweepIntervalMs);
|
|
70
|
+
this.retentionSweepTimer.unref?.();
|
|
48
71
|
logger.log('info', 'SecurityPolicyManager started');
|
|
49
72
|
}
|
|
50
73
|
|
|
51
74
|
public async stop(): Promise<void> {
|
|
52
75
|
this.isStopping = true;
|
|
76
|
+
if (this.retentionSweepTimer) {
|
|
77
|
+
clearInterval(this.retentionSweepTimer);
|
|
78
|
+
this.retentionSweepTimer = undefined;
|
|
79
|
+
}
|
|
53
80
|
this.observationQueue.length = 0;
|
|
54
81
|
this.queuedObservations.clear();
|
|
55
82
|
await this.smartNetwork.stop();
|
|
83
|
+
await Promise.allSettled([...this.inFlightObservations.values()]);
|
|
84
|
+
await this.retentionSweepPromise;
|
|
85
|
+
await this.retentionMutationTail;
|
|
56
86
|
}
|
|
57
87
|
|
|
58
88
|
public async observeIps(ips: string[]): Promise<void> {
|
|
@@ -88,6 +118,7 @@ export class SecurityPolicyManager {
|
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
|
|
121
|
+
if (this.isStopping) return;
|
|
91
122
|
const ip = this.normalizeIp(ipAddress);
|
|
92
123
|
if (!ip || !this.isPublicIp(ip)) {
|
|
93
124
|
return;
|
|
@@ -110,7 +141,7 @@ export class SecurityPolicyManager {
|
|
|
110
141
|
|
|
111
142
|
private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
|
|
112
143
|
try {
|
|
113
|
-
const now =
|
|
144
|
+
const now = this.now();
|
|
114
145
|
let doc = await IpIntelligenceDoc.findByIp(ip);
|
|
115
146
|
if (doc && !options.force && now - doc.updatedAt < this.intelligenceRefreshMs) {
|
|
116
147
|
if (now - doc.lastSeenAt > 60_000) {
|
|
@@ -122,16 +153,18 @@ export class SecurityPolicyManager {
|
|
|
122
153
|
}
|
|
123
154
|
|
|
124
155
|
const intelligence = await this.smartNetwork.getIpIntelligence(ip);
|
|
125
|
-
if (
|
|
126
|
-
doc
|
|
127
|
-
doc.
|
|
128
|
-
doc.
|
|
156
|
+
if (doc) {
|
|
157
|
+
Object.assign(doc, intelligence);
|
|
158
|
+
doc.lastSeenAt = now;
|
|
159
|
+
doc.updatedAt = now;
|
|
160
|
+
doc.seenCount = (doc.seenCount || 0) + 1;
|
|
161
|
+
await doc.save();
|
|
162
|
+
} else {
|
|
163
|
+
doc = await this.persistNewIntelligence(ip, intelligence, now);
|
|
164
|
+
if (!doc) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
129
167
|
}
|
|
130
|
-
Object.assign(doc, intelligence);
|
|
131
|
-
doc.lastSeenAt = now;
|
|
132
|
-
doc.updatedAt = now;
|
|
133
|
-
doc.seenCount = (doc.seenCount || 0) + 1;
|
|
134
|
-
await doc.save();
|
|
135
168
|
|
|
136
169
|
if (await this.matchesAnyReactiveRule(doc)) {
|
|
137
170
|
await this.notifyPolicyChanged();
|
|
@@ -147,20 +180,154 @@ export class SecurityPolicyManager {
|
|
|
147
180
|
|
|
148
181
|
public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
|
|
149
182
|
const limit = Number.isInteger(options.limit) && options.limit! > 0
|
|
150
|
-
? Math.min(options.limit!,
|
|
151
|
-
:
|
|
152
|
-
|
|
153
|
-
let
|
|
154
|
-
if (options.ipAddresses
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
183
|
+
? Math.min(options.limit!, IP_INTELLIGENCE_LIST_LIMIT)
|
|
184
|
+
: IP_INTELLIGENCE_LIST_LIMIT;
|
|
185
|
+
|
|
186
|
+
let ipAddresses: string[] | undefined;
|
|
187
|
+
if (options.ipAddresses !== undefined) {
|
|
188
|
+
if (!Array.isArray(options.ipAddresses)) {
|
|
189
|
+
throw new Error('ipAddresses must be an array');
|
|
190
|
+
}
|
|
191
|
+
const normalizedIps = options.ipAddresses.map((ipAddress, index) => {
|
|
192
|
+
if (typeof ipAddress !== 'string') {
|
|
193
|
+
throw new Error('ipAddresses[' + index + '] must be a string');
|
|
194
|
+
}
|
|
195
|
+
const normalizedIp = this.normalizeIp(ipAddress);
|
|
196
|
+
if (!normalizedIp) {
|
|
197
|
+
throw new Error('ipAddresses[' + index + '] is not a valid IP address');
|
|
198
|
+
}
|
|
199
|
+
return normalizedIp;
|
|
200
|
+
});
|
|
201
|
+
ipAddresses = [...new Set(normalizedIps)];
|
|
202
|
+
if (ipAddresses.length > IP_INTELLIGENCE_LIST_LIMIT) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'ipAddresses may contain at most ' + IP_INTELLIGENCE_LIST_LIMIT + ' unique addresses',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (ipAddresses.length === 0) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const docs = await IpIntelligenceDoc.findRecent({ ipAddresses, limit });
|
|
213
|
+
return docs.map((doc) => this.intelligenceFromDoc(doc));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async persistNewIntelligence(
|
|
217
|
+
ip: string,
|
|
218
|
+
intelligence: Awaited<ReturnType<plugins.smartnetwork.SmartNetwork['getIpIntelligence']>>,
|
|
219
|
+
now: number,
|
|
220
|
+
): Promise<IpIntelligenceDoc | null> {
|
|
221
|
+
return await this.withRetentionMutation(async () => {
|
|
222
|
+
if (this.isStopping) return null;
|
|
223
|
+
let doc = await IpIntelligenceDoc.findByIp(ip);
|
|
224
|
+
if (!doc) {
|
|
225
|
+
let countAfterPrune: number;
|
|
226
|
+
try {
|
|
227
|
+
countAfterPrune = await this.pruneIpIntelligence({ forCapacity: true });
|
|
228
|
+
} catch (error: unknown) {
|
|
229
|
+
logger.log(
|
|
230
|
+
'warn',
|
|
231
|
+
'Skipping persistence for new IP ' + ip
|
|
232
|
+
+ ': cache capacity could not be verified: ' + (error as Error).message,
|
|
233
|
+
);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
if (countAfterPrune >= IP_INTELLIGENCE_RETENTION_HIGH_WATER) {
|
|
237
|
+
logger.log(
|
|
238
|
+
'warn',
|
|
239
|
+
'Skipping persistence for new IP ' + ip
|
|
240
|
+
+ ': cache remains at capacity (' + countAfterPrune + ')',
|
|
241
|
+
);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
doc = new IpIntelligenceDoc();
|
|
245
|
+
doc.ipAddress = ip;
|
|
246
|
+
doc.firstSeenAt = now;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
Object.assign(doc, intelligence);
|
|
250
|
+
doc.lastSeenAt = now;
|
|
251
|
+
doc.updatedAt = now;
|
|
252
|
+
doc.seenCount = (doc.seenCount || 0) + 1;
|
|
253
|
+
await doc.save();
|
|
254
|
+
return doc;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async requestRetentionSweep(): Promise<void> {
|
|
259
|
+
if (this.retentionSweepPromise) {
|
|
260
|
+
return await this.retentionSweepPromise;
|
|
160
261
|
}
|
|
161
262
|
|
|
162
|
-
const
|
|
163
|
-
|
|
263
|
+
const sweepPromise = this.withRetentionMutation(async () => {
|
|
264
|
+
await this.pruneIpIntelligence({ forCapacity: false });
|
|
265
|
+
}).catch((error: unknown) => {
|
|
266
|
+
logger.log(
|
|
267
|
+
'warn',
|
|
268
|
+
'IP intelligence retention sweep failed: ' + (error as Error).message,
|
|
269
|
+
);
|
|
270
|
+
}).finally(() => {
|
|
271
|
+
if (this.retentionSweepPromise === sweepPromise) {
|
|
272
|
+
this.retentionSweepPromise = undefined;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
this.retentionSweepPromise = sweepPromise;
|
|
276
|
+
return await sweepPromise;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async pruneIpIntelligence(options: { forCapacity: boolean }): Promise<number> {
|
|
280
|
+
let deletedCount = 0;
|
|
281
|
+
const expiryCutoff = this.now() - IP_INTELLIGENCE_RETENTION_MAX_AGE_MS;
|
|
282
|
+
|
|
283
|
+
while (!this.isStopping) {
|
|
284
|
+
const expiredIds = await IpIntelligenceDoc.findOldestIds({
|
|
285
|
+
lastSeenBefore: expiryCutoff,
|
|
286
|
+
limit: IP_INTELLIGENCE_RETENTION_BATCH_SIZE,
|
|
287
|
+
});
|
|
288
|
+
if (expiredIds.length === 0) break;
|
|
289
|
+
deletedCount += await IpIntelligenceDoc.deleteByDocumentIds(expiredIds);
|
|
290
|
+
if (expiredIds.length < IP_INTELLIGENCE_RETENTION_BATCH_SIZE) break;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let currentCount = await IpIntelligenceDoc.countAll();
|
|
294
|
+
const shouldPruneExcess = currentCount > IP_INTELLIGENCE_RETENTION_HIGH_WATER
|
|
295
|
+
|| (options.forCapacity && currentCount >= IP_INTELLIGENCE_RETENTION_HIGH_WATER);
|
|
296
|
+
if (shouldPruneExcess) {
|
|
297
|
+
let remainingToDelete = currentCount - IP_INTELLIGENCE_RETENTION_LOW_WATER;
|
|
298
|
+
while (remainingToDelete > 0 && !this.isStopping) {
|
|
299
|
+
const batchLimit = Math.min(
|
|
300
|
+
remainingToDelete,
|
|
301
|
+
IP_INTELLIGENCE_RETENTION_BATCH_SIZE,
|
|
302
|
+
);
|
|
303
|
+
const oldestIds = await IpIntelligenceDoc.findOldestIds({ limit: batchLimit });
|
|
304
|
+
if (oldestIds.length === 0) break;
|
|
305
|
+
const deletedThisBatch = await IpIntelligenceDoc.deleteByDocumentIds(oldestIds);
|
|
306
|
+
deletedCount += deletedThisBatch;
|
|
307
|
+
currentCount -= deletedThisBatch;
|
|
308
|
+
remainingToDelete -= deletedThisBatch;
|
|
309
|
+
if (deletedThisBatch === 0 || oldestIds.length < batchLimit) break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (deletedCount > 0) {
|
|
314
|
+
logger.log('info', 'IP intelligence retention removed ' + deletedCount + ' cached record(s)');
|
|
315
|
+
}
|
|
316
|
+
return currentCount;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async withRetentionMutation<T>(operation: () => Promise<T>): Promise<T> {
|
|
320
|
+
const previous = this.retentionMutationTail;
|
|
321
|
+
let release!: () => void;
|
|
322
|
+
this.retentionMutationTail = new Promise<void>((resolve) => {
|
|
323
|
+
release = resolve;
|
|
324
|
+
});
|
|
325
|
+
await previous;
|
|
326
|
+
try {
|
|
327
|
+
return await operation();
|
|
328
|
+
} finally {
|
|
329
|
+
release();
|
|
330
|
+
}
|
|
164
331
|
}
|
|
165
332
|
|
|
166
333
|
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
|
|
@@ -443,10 +610,10 @@ export class SecurityPolicyManager {
|
|
|
443
610
|
|
|
444
611
|
private normalizeIp(ipAddress: string): string | undefined {
|
|
445
612
|
const ip = ipAddress.trim();
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return plugins.net.isIP(
|
|
613
|
+
const normalizedIp = ip.startsWith('::ffff:')
|
|
614
|
+
? ip.slice('::ffff:'.length)
|
|
615
|
+
: ip;
|
|
616
|
+
return plugins.net.isIP(normalizedIp) ? normalizedIp : undefined;
|
|
450
617
|
}
|
|
451
618
|
|
|
452
619
|
private normalizeCidr(value: string): string | undefined {
|