@serve.zone/dcrouter 17.1.4 → 17.1.5

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.
@@ -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.5',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }