@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.
Files changed (49) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +559 -559
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.js +9 -3
  5. package/dist_ts/db/documents/classes.ip-intelligence.doc.d.ts +10 -0
  6. package/dist_ts/db/documents/classes.ip-intelligence.doc.js +63 -1
  7. package/dist_ts/dns/classes.dns-server-runtime.d.ts +1 -0
  8. package/dist_ts/dns/classes.dns-server-runtime.js +8 -4
  9. package/dist_ts/dns/manager.dns.js +23 -6
  10. package/dist_ts/email/classes.email-domain.manager.js +27 -15
  11. package/dist_ts/email/classes.mail-dns-sync.d.ts +19 -2
  12. package/dist_ts/email/classes.mail-dns-sync.js +158 -32
  13. package/dist_ts/email/index.d.ts +1 -0
  14. package/dist_ts/email/index.js +2 -1
  15. package/dist_ts/email/mail-dns-status.d.ts +5 -0
  16. package/dist_ts/email/mail-dns-status.js +31 -0
  17. package/dist_ts/monitoring/classes.metricscache.d.ts +2 -0
  18. package/dist_ts/monitoring/classes.metricscache.js +28 -6
  19. package/dist_ts/monitoring/classes.metricsmanager.d.ts +10 -0
  20. package/dist_ts/monitoring/classes.metricsmanager.js +99 -31
  21. package/dist_ts/opsserver/handlers/security.handler.js +24 -6
  22. package/dist_ts/opsserver/handlers/stats.handler.js +2 -3
  23. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -2
  24. package/dist_ts/security/classes.security-policy-manager.d.ts +17 -0
  25. package/dist_ts/security/classes.security-policy-manager.js +171 -26
  26. package/dist_ts_interfaces/requests/stats.d.ts +7 -0
  27. package/dist_ts_migrations/index.js +395 -9
  28. package/dist_ts_web/00_commitinfo_data.js +1 -1
  29. package/dist_ts_web/appstate/network.js +11 -2
  30. package/dist_ts_web/appstate/ui.js +1 -8
  31. package/package.json +3 -3
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts/classes.dcrouter.ts +11 -2
  34. package/ts/db/documents/classes.ip-intelligence.doc.ts +75 -0
  35. package/ts/dns/classes.dns-server-runtime.ts +9 -3
  36. package/ts/dns/manager.dns.ts +22 -5
  37. package/ts/email/classes.email-domain.manager.ts +23 -16
  38. package/ts/email/classes.mail-dns-sync.ts +172 -38
  39. package/ts/email/index.ts +1 -0
  40. package/ts/email/mail-dns-status.ts +35 -0
  41. package/ts/monitoring/classes.metricscache.ts +36 -8
  42. package/ts/monitoring/classes.metricsmanager.ts +113 -32
  43. package/ts/opsserver/handlers/security.handler.ts +35 -6
  44. package/ts/opsserver/handlers/stats.handler.ts +1 -2
  45. package/ts/remoteingress/classes.hub-lifecycle.ts +3 -1
  46. package/ts/security/classes.security-policy-manager.ts +193 -26
  47. package/ts_web/00_commitinfo_data.ts +1 -1
  48. package/ts_web/appstate/network.ts +12 -1
  49. 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
- // Initialize metrics cache with 500ms TTL
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 cacheKey = `activeConnectionSnapshots:${options.limit ?? 1000}:${options.routeId ?? ''}`;
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(options);
304
- }, 500);
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 activeConnectionSnapshots = await this.getActiveConnectionSnapshots({ limit: 10000 });
591
-
592
- const connectionsByIP = new Map<string, number>();
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: activeConnectionsByDomain.get(domain) ?? Math.round(totalConns),
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
- }, 1000); // 1s cache — matches typical dashboard poll interval
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
- if (!manager || observedIps.length === 0) {
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 manager.listIpIntelligence({
944
- ipAddresses: observedIps,
945
- limit: Math.max(100, observedIps.length),
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 connectionInfos = await this.getActiveConnections(dataArg.protocol, dataArg.state);
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
- ): Promise<interfaces.data.IConnectionInfo[]> {
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 snapshots = await metricsManager.getActiveConnectionSnapshots({ limit: 10000 });
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
- return connections.filter((connection) => {
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: serverStats.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) => this.dcRouterRef.mailDnsSync.requestSync(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 = Date.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 (!doc) {
126
- doc = new IpIntelligenceDoc();
127
- doc.ipAddress = ip;
128
- doc.firstSeenAt = now;
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!, 500)
151
- : undefined;
152
-
153
- let docs: IpIntelligenceDoc[];
154
- if (options.ipAddresses?.length) {
155
- const ips = [...new Set(options.ipAddresses.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
156
- const results = await Promise.all(ips.map((ip) => IpIntelligenceDoc.findByIp(ip)));
157
- docs = results.filter(Boolean) as IpIntelligenceDoc[];
158
- } else {
159
- docs = await IpIntelligenceDoc.findAll();
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 sortedDocs = docs.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
163
- return (limit ? sortedDocs.slice(0, limit) : sortedDocs).map((doc) => this.intelligenceFromDoc(doc));
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
- if (ip.startsWith('::ffff:')) {
447
- return ip.slice('::ffff:'.length);
448
- }
449
- return plugins.net.isIP(ip) ? ip : undefined;
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 {
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.1.4',
6
+ version: '17.1.6',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }