@serve.zone/dcrouter 13.32.1 → 13.33.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "13.32.1",
4
+ "version": "13.33.0",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "exports": {
@@ -43,7 +43,7 @@
43
43
  "@push.rocks/smartmetrics": "^3.0.3",
44
44
  "@push.rocks/smartmigration": "1.4.1",
45
45
  "@push.rocks/smartmta": "^5.3.3",
46
- "@push.rocks/smartnetwork": "^4.7.1",
46
+ "@push.rocks/smartnetwork": "^4.7.2",
47
47
  "@push.rocks/smartpath": "^6.0.0",
48
48
  "@push.rocks/smartpromise": "^4.2.4",
49
49
  "@push.rocks/smartproxy": "^27.10.3",
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.32.1',
6
+ version: '13.33.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -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
  );
@@ -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,
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.32.1',
6
+ version: '13.33.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -582,6 +582,52 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
582
582
  };
583
583
  });
584
584
 
585
+ const backgroundRefreshesInFlight = new Set<string>();
586
+
587
+ function runBackgroundRefresh(key: string, errorMessage: string, task: () => Promise<void>): void {
588
+ if (backgroundRefreshesInFlight.has(key)) return;
589
+ backgroundRefreshesInFlight.add(key);
590
+ void task()
591
+ .catch((error) => console.error(errorMessage, error))
592
+ .finally(() => backgroundRefreshesInFlight.delete(key));
593
+ }
594
+
595
+ function refreshNetworkIpIntelligence(identity: interfaces.data.IIdentity, ipAddresses: string[]): void {
596
+ const ips = [...new Set(ipAddresses.filter(Boolean))].slice(0, 100);
597
+ if (ips.length === 0) return;
598
+
599
+ runBackgroundRefresh('networkIpIntelligence', 'IP intelligence refresh failed:', async () => {
600
+ const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
601
+ interfaces.requests.IReq_ListIpIntelligence
602
+ >('/typedrequest', 'listIpIntelligence');
603
+ const intelligenceResponse = await intelligenceRequest.fire({
604
+ identity,
605
+ ipAddresses: ips,
606
+ limit: Math.max(100, ips.length),
607
+ });
608
+ networkStatePart.setState({
609
+ ...networkStatePart.getState()!,
610
+ ipIntelligence: intelligenceResponse.records || [],
611
+ });
612
+ });
613
+ }
614
+
615
+ function refreshSecurityIpIntelligence(identity: interfaces.data.IIdentity): void {
616
+ runBackgroundRefresh('securityIpIntelligence', 'Security IP intelligence refresh failed:', async () => {
617
+ const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
618
+ interfaces.requests.IReq_ListIpIntelligence
619
+ >('/typedrequest', 'listIpIntelligence');
620
+ const intelligenceResponse = await intelligenceRequest.fire({
621
+ identity,
622
+ limit: 500,
623
+ });
624
+ securityPolicyStatePart.setState({
625
+ ...securityPolicyStatePart.getState()!,
626
+ ipIntelligence: intelligenceResponse.records || [],
627
+ });
628
+ });
629
+ }
630
+
585
631
  // Fetch Network Stats Action
586
632
  export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg): Promise<INetworkState> => {
587
633
  const context = getActionContext();
@@ -594,18 +640,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
594
640
  interfaces.requests.IReq_GetNetworkStats
595
641
  >('/typedrequest', 'getNetworkStats');
596
642
 
597
- const ipIntelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
598
- interfaces.requests.IReq_ListIpIntelligence
599
- >('/typedrequest', 'listIpIntelligence');
600
-
601
- const [networkStatsResponse, ipIntelligenceResponse] = await Promise.all([
602
- networkStatsRequest.fire({
603
- identity: context.identity,
604
- }),
605
- ipIntelligenceRequest.fire({
606
- identity: context.identity,
607
- }),
608
- ]);
643
+ const networkStatsResponse = await networkStatsRequest.fire({
644
+ identity: context.identity,
645
+ });
609
646
 
610
647
  // Use the connections data for the connection list
611
648
  // and network stats for throughput and IP analytics
@@ -637,6 +674,12 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
637
674
  };
638
675
  });
639
676
 
677
+ refreshNetworkIpIntelligence(context.identity, [
678
+ ...Object.keys(connectionsByIP),
679
+ ...(networkStatsResponse.topIPs || []).map((item) => item.ip),
680
+ ...(networkStatsResponse.topIPsByBandwidth || []).map((item) => item.ip),
681
+ ]);
682
+
640
683
  return {
641
684
  connections,
642
685
  connectionsByIP,
@@ -647,7 +690,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
647
690
  topIPs: networkStatsResponse.topIPs || [],
648
691
  topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
649
692
  throughputByIP: networkStatsResponse.throughputByIP || [],
650
- ipIntelligence: ipIntelligenceResponse.records || [],
693
+ ipIntelligence: currentState.ipIntelligence,
651
694
  domainActivity: networkStatsResponse.domainActivity || [],
652
695
  throughputHistory: networkStatsResponse.throughputHistory || [],
653
696
  requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
@@ -683,9 +726,6 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
683
726
  const rulesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
684
727
  interfaces.requests.IReq_ListSecurityBlockRules
685
728
  >('/typedrequest', 'listSecurityBlockRules');
686
- const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
687
- interfaces.requests.IReq_ListIpIntelligence
688
- >('/typedrequest', 'listIpIntelligence');
689
729
  const compiledPolicyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
690
730
  interfaces.requests.IReq_GetCompiledSecurityPolicy
691
731
  >('/typedrequest', 'getCompiledSecurityPolicy');
@@ -693,16 +733,17 @@ export const fetchSecurityPolicyAction = securityPolicyStatePart.createAction(
693
733
  interfaces.requests.IReq_ListSecurityPolicyAudit
694
734
  >('/typedrequest', 'listSecurityPolicyAudit');
695
735
 
696
- const [rulesResponse, intelligenceResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
736
+ const [rulesResponse, compiledPolicyResponse, auditResponse] = await Promise.all([
697
737
  rulesRequest.fire({ identity: context.identity }),
698
- intelligenceRequest.fire({ identity: context.identity }),
699
738
  compiledPolicyRequest.fire({ identity: context.identity }),
700
739
  auditRequest.fire({ identity: context.identity, limit: 100 }),
701
740
  ]);
702
741
 
742
+ refreshSecurityIpIntelligence(context.identity);
743
+
703
744
  return {
704
745
  rules: rulesResponse.rules || [],
705
- ipIntelligence: intelligenceResponse.records || [],
746
+ ipIntelligence: currentState.ipIntelligence,
706
747
  compiledPolicy: compiledPolicyResponse.policy,
707
748
  auditEvents: auditResponse.events || [],
708
749
  isLoading: false,
@@ -835,7 +876,15 @@ export const refreshIpIntelligenceAction = securityPolicyStatePart.createAction<
835
876
  if (!response.success) {
836
877
  return { ...currentState, error: response.message || 'Failed to refresh IP intelligence' };
837
878
  }
838
- return await actionContext!.dispatch(fetchSecurityPolicyAction, null);
879
+ const refreshedState = await actionContext!.dispatch(fetchSecurityPolicyAction, null);
880
+ if (!response.record) return refreshedState;
881
+ return {
882
+ ...refreshedState,
883
+ ipIntelligence: [
884
+ response.record,
885
+ ...refreshedState.ipIntelligence.filter((record) => record.ipAddress !== response.record!.ipAddress),
886
+ ],
887
+ };
839
888
  } catch (error: unknown) {
840
889
  return {
841
890
  ...currentState,
@@ -3112,53 +3161,38 @@ async function dispatchCombinedRefreshActionInner() {
3112
3161
  error: null,
3113
3162
  });
3114
3163
 
3115
- try {
3116
- const intelligenceRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
3117
- interfaces.requests.IReq_ListIpIntelligence
3118
- >('/typedrequest', 'listIpIntelligence');
3119
- const intelligenceResponse = await intelligenceRequest.fire({ identity: context.identity });
3120
- networkStatePart.setState({
3121
- ...networkStatePart.getState()!,
3122
- ipIntelligence: intelligenceResponse.records || [],
3123
- });
3124
- } catch (error) {
3125
- console.error('IP intelligence refresh failed:', error);
3126
- }
3164
+ refreshNetworkIpIntelligence(context.identity, [
3165
+ ...network.connectionDetails.map((conn) => conn.remoteAddress),
3166
+ ...network.topEndpoints.map((endpoint) => endpoint.endpoint),
3167
+ ...(network.topEndpointsByBandwidth || []).map((endpoint) => endpoint.endpoint),
3168
+ ]);
3127
3169
  }
3128
3170
 
3129
3171
  if (currentView === 'security') {
3130
- try {
3172
+ runBackgroundRefresh('securityPolicy', 'Security policy refresh failed:', async () => {
3131
3173
  await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null);
3132
- } catch (error) {
3133
- console.error('Security policy refresh failed:', error);
3134
- }
3174
+ });
3135
3175
  }
3136
3176
 
3137
3177
  // Refresh certificate data if on Domains > Certificates subview
3138
3178
  if (currentView === 'domains' && currentSubview === 'certificates') {
3139
- try {
3179
+ runBackgroundRefresh('certificates', 'Certificate refresh failed:', async () => {
3140
3180
  await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
3141
- } catch (error) {
3142
- console.error('Certificate refresh failed:', error);
3143
- }
3181
+ });
3144
3182
  }
3145
3183
 
3146
3184
  // Refresh remote ingress data if on the Network → Remote Ingress subview
3147
3185
  if (currentView === 'network' && currentSubview === 'remoteingress') {
3148
- try {
3186
+ runBackgroundRefresh('remoteIngress', 'Remote ingress refresh failed:', async () => {
3149
3187
  await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
3150
- } catch (error) {
3151
- console.error('Remote ingress refresh failed:', error);
3152
- }
3188
+ });
3153
3189
  }
3154
3190
 
3155
3191
  // Refresh VPN data if on the Network → VPN subview
3156
3192
  if (currentView === 'network' && currentSubview === 'vpn') {
3157
- try {
3193
+ runBackgroundRefresh('vpn', 'VPN refresh failed:', async () => {
3158
3194
  await vpnStatePart.dispatchAction(fetchVpnAction, null);
3159
- } catch (error) {
3160
- console.error('VPN refresh failed:', error);
3161
- }
3195
+ });
3162
3196
  }
3163
3197
  } catch (error) {
3164
3198
  console.error('Combined refresh failed:', error);