@serve.zone/dcrouter 13.32.1 → 13.34.0

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 (40) hide show
  1. package/dist_serve/bundle.js +597 -590
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.js +9 -4
  4. package/dist_ts/config/classes.target-profile-manager.d.ts +16 -3
  5. package/dist_ts/config/classes.target-profile-manager.js +197 -6
  6. package/dist_ts/db/documents/classes.target-profile.doc.d.ts +1 -0
  7. package/dist_ts/db/documents/classes.target-profile.doc.js +8 -2
  8. package/dist_ts/monitoring/classes.metricsmanager.js +5 -2
  9. package/dist_ts/opsserver/handlers/security.handler.js +9 -2
  10. package/dist_ts/opsserver/handlers/target-profile.handler.js +3 -1
  11. package/dist_ts/opsserver/handlers/vpn.handler.js +3 -1
  12. package/dist_ts/security/classes.security-policy-manager.d.ts +15 -1
  13. package/dist_ts/security/classes.security-policy-manager.js +108 -9
  14. package/dist_ts/vpn/classes.vpn-manager.d.ts +15 -1
  15. package/dist_ts/vpn/classes.vpn-manager.js +138 -6
  16. package/dist_ts_interfaces/data/target-profile.d.ts +2 -0
  17. package/dist_ts_interfaces/data/vpn.d.ts +4 -0
  18. package/dist_ts_interfaces/requests/security-policy.d.ts +2 -0
  19. package/dist_ts_interfaces/requests/target-profiles.d.ts +2 -0
  20. package/dist_ts_web/00_commitinfo_data.js +1 -1
  21. package/dist_ts_web/appstate.d.ts +2 -0
  22. package/dist_ts_web/appstate.js +77 -47
  23. package/dist_ts_web/elements/network/ops-view-targetprofiles.js +10 -1
  24. package/dist_ts_web/elements/network/ops-view-vpn.js +3 -1
  25. package/package.json +2 -2
  26. package/readme.md +13 -0
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/classes.dcrouter.ts +10 -2
  29. package/ts/config/classes.target-profile-manager.ts +229 -5
  30. package/ts/db/documents/classes.target-profile.doc.ts +3 -0
  31. package/ts/monitoring/classes.metricsmanager.ts +4 -1
  32. package/ts/opsserver/handlers/security.handler.ts +8 -1
  33. package/ts/opsserver/handlers/target-profile.handler.ts +2 -0
  34. package/ts/opsserver/handlers/vpn.handler.ts +2 -0
  35. package/ts/security/classes.security-policy-manager.ts +115 -7
  36. package/ts/vpn/classes.vpn-manager.ts +158 -3
  37. package/ts_web/00_commitinfo_data.ts +1 -1
  38. package/ts_web/appstate.ts +86 -48
  39. package/ts_web/elements/network/ops-view-targetprofiles.ts +9 -0
  40. package/ts_web/elements/network/ops-view-vpn.ts +2 -0
@@ -5,6 +5,8 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
5
5
  import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
6
6
  import type { IRoute } from '../../ts_interfaces/data/route-management.js';
7
7
 
8
+ type TIpAllowEntry = string | { ip: string; domains?: string[] };
9
+
8
10
  /**
9
11
  * Manages TargetProfiles (target-side: what can be accessed).
10
12
  * TargetProfiles define what resources a VPN client can reach:
@@ -35,6 +37,7 @@ export class TargetProfileManager {
35
37
  domains?: string[];
36
38
  targets?: ITargetProfileTarget[];
37
39
  routeRefs?: string[];
40
+ allowRoutesByClientSourceIp?: boolean;
38
41
  createdBy: string;
39
42
  }): Promise<string> {
40
43
  // Enforce unique profile names
@@ -55,6 +58,7 @@ export class TargetProfileManager {
55
58
  domains: data.domains,
56
59
  targets: data.targets,
57
60
  routeRefs,
61
+ allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
58
62
  createdAt: now,
59
63
  updatedAt: now,
60
64
  createdBy: data.createdBy,
@@ -88,6 +92,9 @@ export class TargetProfileManager {
88
92
  if (patch.domains !== undefined) profile.domains = patch.domains;
89
93
  if (patch.targets !== undefined) profile.targets = patch.targets;
90
94
  if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
95
+ if (patch.allowRoutesByClientSourceIp !== undefined) {
96
+ profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
97
+ }
91
98
  profile.updatedAt = Date.now();
92
99
 
93
100
  await this.persistProfile(profile);
@@ -208,13 +215,15 @@ export class TargetProfileManager {
208
215
  *
209
216
  * Entries are domain-scoped when a profile matches via specific domains that are
210
217
  * a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
211
- * or when profile domains exactly equal the route's domains.
218
+ * or when profile domains exactly equal the route's domains. Profiles can also opt
219
+ * into source-IP matching against non-vpnOnly route security.
212
220
  */
213
221
  public getMatchingClientIps(
214
222
  route: IDcRouterRouteConfig,
215
223
  routeId: string | undefined,
216
224
  clients: VpnClientDoc[],
217
225
  allRoutes: Map<string, IRoute> = new Map(),
226
+ clientSourceIps: Map<string, string> = new Map(),
218
227
  ): Array<string | { ip: string; domains: string[] }> {
219
228
  const entries: Array<string | { ip: string; domains: string[] }> = [];
220
229
  const routeDomains = this.getRouteDomains(route);
@@ -227,6 +236,7 @@ export class TargetProfileManager {
227
236
  // Collect scoped domains from all matching profiles for this client
228
237
  let fullAccess = false;
229
238
  const scopedDomains = new Set<string>();
239
+ const clientSourceIp = clientSourceIps.get(client.clientId);
230
240
 
231
241
  for (const profileId of client.targetProfileIds) {
232
242
  const profile = this.profiles.get(profileId);
@@ -246,6 +256,16 @@ export class TargetProfileManager {
246
256
  if (matchResult !== 'none') {
247
257
  for (const d of matchResult.domains) scopedDomains.add(d);
248
258
  }
259
+
260
+ if (
261
+ !route.vpnOnly
262
+ && profile.allowRoutesByClientSourceIp === true
263
+ && clientSourceIp
264
+ && this.routeAllowsSourceIp(route, clientSourceIp, routeDomains)
265
+ ) {
266
+ fullAccess = true;
267
+ break;
268
+ }
249
269
  }
250
270
 
251
271
  if (fullAccess) {
@@ -265,6 +285,7 @@ export class TargetProfileManager {
265
285
  public getClientAccessSpec(
266
286
  targetProfileIds: string[],
267
287
  allRoutes: Map<string, IRoute>,
288
+ clientSourceIp?: string,
268
289
  ): { domains: string[]; targetIps: string[] } {
269
290
  const domains = new Set<string>();
270
291
  const targetIps = new Set<string>();
@@ -292,13 +313,20 @@ export class TargetProfileManager {
292
313
  // Route references: scan all routes
293
314
  for (const [routeId, route] of allRoutes) {
294
315
  if (!route.enabled) continue;
295
- if (this.routeMatchesProfile(
296
- route.route as IDcRouterRouteConfig,
316
+ const dcRoute = route.route as IDcRouterRouteConfig;
317
+ const routeDomains = this.getRouteDomains(dcRoute);
318
+ const profileMatchesRoute = this.routeMatchesProfile(
319
+ dcRoute,
297
320
  routeId,
298
321
  profile,
299
322
  routeNameIndex,
300
- )) {
301
- for (const d of this.getRouteDomains(route.route as IDcRouterRouteConfig)) {
323
+ );
324
+ const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
325
+ && clientSourceIp
326
+ && !dcRoute.vpnOnly
327
+ && this.routeAllowsSourceIp(dcRoute, clientSourceIp, routeDomains);
328
+ if (profileMatchesRoute || sourceIpMatchesRoute) {
329
+ for (const d of routeDomains) {
302
330
  domains.add(d);
303
331
  }
304
332
  }
@@ -422,6 +450,199 @@ export class TargetProfileManager {
422
450
  return false;
423
451
  }
424
452
 
453
+ private routeAllowsSourceIp(
454
+ route: IDcRouterRouteConfig,
455
+ sourceIp: string,
456
+ routeDomains: string[],
457
+ ): boolean {
458
+ const security = (route as any).security;
459
+ const ipAllowList = this.normalizeIpEntries(security?.ipAllowList);
460
+ const ipBlockList = this.normalizeIpEntries(security?.ipBlockList);
461
+
462
+ if (this.ipEntriesMatchSource(ipBlockList, sourceIp, routeDomains)) {
463
+ return false;
464
+ }
465
+
466
+ if (!ipAllowList.length) {
467
+ return true;
468
+ }
469
+
470
+ return this.ipEntriesMatchSource(ipAllowList, sourceIp, routeDomains);
471
+ }
472
+
473
+ private normalizeIpEntries(entries: unknown): TIpAllowEntry[] {
474
+ if (!entries) return [];
475
+ if (Array.isArray(entries)) return entries as TIpAllowEntry[];
476
+ return [entries as TIpAllowEntry];
477
+ }
478
+
479
+ private ipEntriesMatchSource(
480
+ entries: TIpAllowEntry[],
481
+ sourceIp: string,
482
+ routeDomains: string[],
483
+ ): boolean {
484
+ return entries.some((entry) => this.ipEntryMatchesSource(entry, sourceIp, routeDomains));
485
+ }
486
+
487
+ private ipEntryMatchesSource(
488
+ entry: TIpAllowEntry,
489
+ sourceIp: string,
490
+ routeDomains: string[],
491
+ ): boolean {
492
+ const ipPattern = typeof entry === 'string' ? entry : entry.ip;
493
+ if (typeof ipPattern !== 'string') return false;
494
+ if (!this.ipPatternMatchesSource(ipPattern, sourceIp)) {
495
+ return false;
496
+ }
497
+
498
+ if (typeof entry === 'string' || !entry.domains?.length) {
499
+ return true;
500
+ }
501
+
502
+ if (!routeDomains.length) {
503
+ return false;
504
+ }
505
+
506
+ return routeDomains.some((routeDomain) =>
507
+ entry.domains!.some((entryDomain) =>
508
+ this.domainMatchesPattern(routeDomain, entryDomain)
509
+ || this.domainMatchesPattern(entryDomain, routeDomain),
510
+ ),
511
+ );
512
+ }
513
+
514
+ private ipPatternMatchesSource(pattern: string, sourceIp: string): boolean {
515
+ const trimmedPattern = pattern.trim();
516
+ const trimmedSourceIp = sourceIp.trim();
517
+ if (!trimmedPattern || !trimmedSourceIp) return false;
518
+ if (trimmedPattern === '*') return true;
519
+ if (trimmedPattern === trimmedSourceIp) return true;
520
+
521
+ if (trimmedPattern.includes('/')) {
522
+ return this.ipMatchesCidr(trimmedSourceIp, trimmedPattern);
523
+ }
524
+
525
+ if (trimmedPattern.includes('-')) {
526
+ return this.ipMatchesRange(trimmedSourceIp, trimmedPattern);
527
+ }
528
+
529
+ if (trimmedPattern.includes('*')) {
530
+ return this.ipMatchesWildcard(trimmedSourceIp, trimmedPattern);
531
+ }
532
+
533
+ return false;
534
+ }
535
+
536
+ private ipMatchesCidr(sourceIp: string, cidr: string): boolean {
537
+ const [networkIp, prefixString] = cidr.split('/');
538
+ if (!networkIp || !prefixString) return false;
539
+ const source = this.ipToComparable(sourceIp);
540
+ const network = this.ipToComparable(networkIp);
541
+ const prefix = Number(prefixString);
542
+ if (!source || !network || source.version !== network.version) return false;
543
+
544
+ const bitCount = source.version === 4 ? 32 : 128;
545
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bitCount) return false;
546
+ if (prefix === 0) return true;
547
+
548
+ const shift = BigInt(bitCount - prefix);
549
+ return (source.value >> shift) === (network.value >> shift);
550
+ }
551
+
552
+ private ipMatchesRange(sourceIp: string, range: string): boolean {
553
+ const [startIp, endIp] = range.split('-').map((part) => part.trim());
554
+ if (!startIp || !endIp) return false;
555
+ const source = this.ipToComparable(sourceIp);
556
+ const start = this.ipToComparable(startIp);
557
+ const end = this.ipToComparable(endIp);
558
+ if (!source || !start || !end) return false;
559
+ if (source.version !== start.version || source.version !== end.version) return false;
560
+ return source.value >= start.value && source.value <= end.value;
561
+ }
562
+
563
+ private ipMatchesWildcard(sourceIp: string, pattern: string): boolean {
564
+ const sourceParts = sourceIp.split('.');
565
+ const patternParts = pattern.split('.');
566
+ if (sourceParts.length !== 4 || patternParts.length !== 4) return false;
567
+
568
+ return patternParts.every((patternPart, index) => {
569
+ if (patternPart === '*') return true;
570
+ return patternPart === sourceParts[index];
571
+ });
572
+ }
573
+
574
+ private ipToComparable(ip: string): { version: 4 | 6; value: bigint } | undefined {
575
+ const normalizedIp = this.normalizeIpLiteral(ip);
576
+ const ipVersion = plugins.net.isIP(normalizedIp);
577
+ if (ipVersion === 4) {
578
+ const parts = normalizedIp.split('.').map((part) => Number(part));
579
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
580
+ return undefined;
581
+ }
582
+ return {
583
+ version: 4,
584
+ value: parts.reduce((value, part) => (value << 8n) + BigInt(part), 0n),
585
+ };
586
+ }
587
+
588
+ if (ipVersion === 6) {
589
+ const parts = this.expandIpv6(normalizedIp);
590
+ if (!parts) return undefined;
591
+ return {
592
+ version: 6,
593
+ value: parts.reduce((value, part) => (value << 16n) + BigInt(part), 0n),
594
+ };
595
+ }
596
+
597
+ return undefined;
598
+ }
599
+
600
+ private normalizeIpLiteral(ip: string): string {
601
+ const trimmed = ip.trim().replace(/^\[|\]$/g, '');
602
+ const zoneIndex = trimmed.indexOf('%');
603
+ const withoutZone = zoneIndex === -1 ? trimmed : trimmed.slice(0, zoneIndex);
604
+ const ipv4MappedPrefix = '::ffff:';
605
+ if (withoutZone.toLowerCase().startsWith(ipv4MappedPrefix)) {
606
+ const mappedIpv4 = withoutZone.slice(ipv4MappedPrefix.length);
607
+ if (plugins.net.isIP(mappedIpv4) === 4) return mappedIpv4;
608
+ }
609
+ return withoutZone;
610
+ }
611
+
612
+ private expandIpv6(ip: string): number[] | undefined {
613
+ let normalizedIp = ip.toLowerCase();
614
+ if (normalizedIp.includes('.')) {
615
+ const lastColonIndex = normalizedIp.lastIndexOf(':');
616
+ const ipv4Part = normalizedIp.slice(lastColonIndex + 1);
617
+ const ipv4Comparable = this.ipToComparable(ipv4Part);
618
+ if (!ipv4Comparable || ipv4Comparable.version !== 4) return undefined;
619
+ const high = Number((ipv4Comparable.value >> 16n) & 0xffffn).toString(16);
620
+ const low = Number(ipv4Comparable.value & 0xffffn).toString(16);
621
+ normalizedIp = `${normalizedIp.slice(0, lastColonIndex)}:${high}:${low}`;
622
+ }
623
+
624
+ const doubleColonParts = normalizedIp.split('::');
625
+ if (doubleColonParts.length > 2) return undefined;
626
+
627
+ const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : [];
628
+ const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : [];
629
+ const missingCount = 8 - head.length - tail.length;
630
+ if (missingCount < 0 || (doubleColonParts.length === 1 && missingCount !== 0)) return undefined;
631
+
632
+ const parts = [
633
+ ...head,
634
+ ...Array(missingCount).fill('0'),
635
+ ...tail,
636
+ ];
637
+ if (parts.length !== 8) return undefined;
638
+
639
+ const numbers = parts.map((part) => Number.parseInt(part || '0', 16));
640
+ if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
641
+ return undefined;
642
+ }
643
+ return numbers;
644
+ }
645
+
425
646
  private getRouteDomains(route: IDcRouterRouteConfig): string[] {
426
647
  const domains = (route.match as any)?.domains;
427
648
  if (!domains) return [];
@@ -503,6 +724,7 @@ export class TargetProfileManager {
503
724
  domains: doc.domains,
504
725
  targets: doc.targets,
505
726
  routeRefs: doc.routeRefs,
727
+ allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
506
728
  createdAt: doc.createdAt,
507
729
  updatedAt: doc.updatedAt,
508
730
  createdBy: doc.createdBy,
@@ -522,6 +744,7 @@ export class TargetProfileManager {
522
744
  existingDoc.domains = profile.domains;
523
745
  existingDoc.targets = profile.targets;
524
746
  existingDoc.routeRefs = profile.routeRefs;
747
+ existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
525
748
  existingDoc.updatedAt = profile.updatedAt;
526
749
  await existingDoc.save();
527
750
  } else {
@@ -532,6 +755,7 @@ export class TargetProfileManager {
532
755
  doc.domains = profile.domains;
533
756
  doc.targets = profile.targets;
534
757
  doc.routeRefs = profile.routeRefs;
758
+ doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
535
759
  doc.createdAt = profile.createdAt;
536
760
  doc.updatedAt = profile.updatedAt;
537
761
  doc.createdBy = profile.createdBy;
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
25
25
  @plugins.smartdata.svDb()
26
26
  public routeRefs?: string[];
27
27
 
28
+ @plugins.smartdata.svDb()
29
+ public allowRoutesByClientSourceIp?: boolean;
30
+
28
31
  @plugins.smartdata.svDb()
29
32
  public createdAt!: number;
30
33
 
@@ -725,7 +725,10 @@ export class MetricsManager {
725
725
  .slice(0, 10)
726
726
  .map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
727
727
 
728
- void this.dcRouter.securityPolicyManager?.observeIps([...allIPData.keys()]);
728
+ this.dcRouter.securityPolicyManager?.queueObservedIps([
729
+ ...topIPs.map((item) => item.ip),
730
+ ...topIPsByBandwidth.map((item) => item.ip),
731
+ ]);
729
732
 
730
733
  // Build domain activity using per-IP domain request counts from Rust engine
731
734
  const connectionsByRoute = proxyMetrics.connections.byRoute();
@@ -180,7 +180,14 @@ export class SecurityHandler {
180
180
  async (dataArg) => {
181
181
  await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'security:read' });
182
182
  const manager = this.opsServerRef.dcRouterRef.securityPolicyManager;
183
- return { records: manager ? await manager.listIpIntelligence() : [] };
183
+ return {
184
+ records: manager
185
+ ? await manager.listIpIntelligence({
186
+ ipAddresses: dataArg.ipAddresses,
187
+ limit: dataArg.limit,
188
+ })
189
+ : [],
190
+ };
184
191
  },
185
192
  ),
186
193
  );
@@ -69,6 +69,7 @@ export class TargetProfileHandler {
69
69
  domains: dataArg.domains,
70
70
  targets: dataArg.targets,
71
71
  routeRefs: dataArg.routeRefs,
72
+ allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
72
73
  createdBy: userId,
73
74
  });
74
75
  await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
@@ -94,6 +95,7 @@ export class TargetProfileHandler {
94
95
  domains: dataArg.domains,
95
96
  targets: dataArg.targets,
96
97
  routeRefs: dataArg.routeRefs,
98
+ allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
97
99
  });
98
100
  // Re-apply routes and refresh VPN client security to update access
99
101
  await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
@@ -102,6 +102,8 @@ export class VpnHandler {
102
102
  bytesSent: c.bytesSent,
103
103
  bytesReceived: c.bytesReceived,
104
104
  transport: c.transportType,
105
+ remoteAddr: c.remoteAddr,
106
+ sourceIp: manager.getClientSourceIp(c.registeredClientId || c.clientId),
105
107
  })),
106
108
  };
107
109
  },
@@ -19,12 +19,24 @@ export interface IRemoteIngressFirewallSnapshot {
19
19
  blockedIps: string[];
20
20
  }
21
21
 
22
+ const OBSERVED_IP_QUEUE_LIMIT = 512;
23
+ const OBSERVED_IP_BATCH_LIMIT = 20;
24
+ const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
25
+ const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
26
+
22
27
  export class SecurityPolicyManager {
23
28
  private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
24
29
  cacheTtl: 24 * 60 * 60 * 1000,
30
+ ipIntelligenceTimeout: 5_000,
25
31
  });
26
32
  private readonly intelligenceRefreshMs: number;
27
- private readonly inFlightObservations = new Set<string>();
33
+ private readonly inFlightObservations = new Map<string, Promise<void>>();
34
+ private readonly queuedObservations = new Set<string>();
35
+ private readonly observationQueue: string[] = [];
36
+ private readonly lastQueuedAt = new Map<string, number>();
37
+ private activeQueuedObservations = 0;
38
+ private queueDrainScheduled = false;
39
+ private isStopping = false;
28
40
  private readonly onPolicyChanged?: () => void | Promise<void>;
29
41
 
30
42
  constructor(options: ISecurityPolicyManagerOptions = {}) {
@@ -37,6 +49,9 @@ export class SecurityPolicyManager {
37
49
  }
38
50
 
39
51
  public async stop(): Promise<void> {
52
+ this.isStopping = true;
53
+ this.observationQueue.length = 0;
54
+ this.queuedObservations.clear();
40
55
  await this.smartNetwork.stop();
41
56
  }
42
57
 
@@ -45,13 +60,55 @@ export class SecurityPolicyManager {
45
60
  await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
46
61
  }
47
62
 
63
+ public queueObservedIps(ips: string[]): void {
64
+ if (this.isStopping) return;
65
+
66
+ const now = Date.now();
67
+ const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
68
+
69
+ for (const ip of uniqueIps.slice(0, OBSERVED_IP_BATCH_LIMIT)) {
70
+ if (!this.isPublicIp(ip)) continue;
71
+ if (this.inFlightObservations.has(ip) || this.queuedObservations.has(ip)) continue;
72
+
73
+ const lastQueuedAt = this.lastQueuedAt.get(ip);
74
+ if (lastQueuedAt && now - lastQueuedAt < OBSERVED_IP_REQUEUE_THROTTLE_MS) continue;
75
+
76
+ if (this.observationQueue.length >= OBSERVED_IP_QUEUE_LIMIT) {
77
+ const droppedIp = this.observationQueue.shift();
78
+ if (droppedIp) this.queuedObservations.delete(droppedIp);
79
+ }
80
+
81
+ this.observationQueue.push(ip);
82
+ this.queuedObservations.add(ip);
83
+ this.lastQueuedAt.set(ip, now);
84
+ }
85
+
86
+ this.pruneQueuedIpMemory(now);
87
+ this.scheduleQueueDrain();
88
+ }
89
+
48
90
  public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
49
91
  const ip = this.normalizeIp(ipAddress);
50
- if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) {
92
+ if (!ip || !this.isPublicIp(ip)) {
51
93
  return;
52
94
  }
53
95
 
54
- this.inFlightObservations.add(ip);
96
+ const existingObservation = this.inFlightObservations.get(ip);
97
+ if (existingObservation) {
98
+ await existingObservation;
99
+ if (!options.force) return;
100
+ }
101
+
102
+ const observationPromise = this.performObserveIp(ip, options).finally(() => {
103
+ if (this.inFlightObservations.get(ip) === observationPromise) {
104
+ this.inFlightObservations.delete(ip);
105
+ }
106
+ });
107
+ this.inFlightObservations.set(ip, observationPromise);
108
+ await observationPromise;
109
+ }
110
+
111
+ private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
55
112
  try {
56
113
  const now = Date.now();
57
114
  let doc = await IpIntelligenceDoc.findByIp(ip);
@@ -81,8 +138,6 @@ export class SecurityPolicyManager {
81
138
  }
82
139
  } catch (err) {
83
140
  logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
84
- } finally {
85
- this.inFlightObservations.delete(ip);
86
141
  }
87
142
  }
88
143
 
@@ -90,8 +145,22 @@ export class SecurityPolicyManager {
90
145
  return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
91
146
  }
92
147
 
93
- public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
94
- return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(doc));
148
+ public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
149
+ 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();
160
+ }
161
+
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));
95
164
  }
96
165
 
97
166
  public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
@@ -104,6 +173,45 @@ export class SecurityPolicyManager {
104
173
  return doc ? this.intelligenceFromDoc(doc) : null;
105
174
  }
106
175
 
176
+ private scheduleQueueDrain(): void {
177
+ if (this.queueDrainScheduled || this.isStopping) return;
178
+ this.queueDrainScheduled = true;
179
+ setTimeout(() => {
180
+ this.queueDrainScheduled = false;
181
+ this.drainObservationQueue();
182
+ }, 0);
183
+ }
184
+
185
+ private drainObservationQueue(): void {
186
+ if (this.isStopping) return;
187
+
188
+ while (
189
+ this.activeQueuedObservations < OBSERVED_IP_QUEUE_CONCURRENCY &&
190
+ this.observationQueue.length > 0
191
+ ) {
192
+ const ip = this.observationQueue.shift()!;
193
+ this.queuedObservations.delete(ip);
194
+ this.activeQueuedObservations++;
195
+ void this.observeIp(ip)
196
+ .catch(() => undefined)
197
+ .finally(() => {
198
+ this.activeQueuedObservations--;
199
+ if (this.observationQueue.length > 0) {
200
+ this.scheduleQueueDrain();
201
+ }
202
+ });
203
+ }
204
+ }
205
+
206
+ private pruneQueuedIpMemory(now: number): void {
207
+ if (this.lastQueuedAt.size <= OBSERVED_IP_QUEUE_LIMIT * 2) return;
208
+ for (const [ip, lastQueuedAt] of this.lastQueuedAt) {
209
+ if (now - lastQueuedAt > OBSERVED_IP_REQUEUE_THROTTLE_MS * 2) {
210
+ this.lastQueuedAt.delete(ip);
211
+ }
212
+ }
213
+ }
214
+
107
215
  public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
108
216
  return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({
109
217
  id: doc.id,