@serve.zone/dcrouter 17.1.3 → 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
+ }
160
210
  }
161
211
 
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));
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;
261
+ }
262
+
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> {
@@ -299,9 +466,9 @@ export class SecurityPolicyManager {
299
466
 
300
467
  public async compilePolicy(): Promise<ISecurityCompiledPolicy> {
301
468
  const rules = await SecurityBlockRuleDoc.findEnabled();
302
- const intelligenceDocs = await IpIntelligenceDoc.findAll();
303
469
  const blockedIps = new Set<string>();
304
470
  const blockedCidrs = new Set<string>();
471
+ const reactiveRules: SecurityBlockRuleDoc[] = [];
305
472
 
306
473
  for (const rule of rules) {
307
474
  const normalizedValue = rule.value.trim();
@@ -320,7 +487,32 @@ export class SecurityPolicyManager {
320
487
  continue;
321
488
  }
322
489
 
323
- for (const doc of intelligenceDocs) {
490
+ if (this.getReactiveRuleQuery(rule)) {
491
+ reactiveRules.push(rule);
492
+ }
493
+ }
494
+
495
+ if (reactiveRules.length === 0) {
496
+ return this.compiledPolicyFromSets(blockedIps, blockedCidrs);
497
+ }
498
+
499
+ const candidateDocs = new Map<string, IpIntelligenceDoc>();
500
+ for (const rule of reactiveRules) {
501
+ const query = this.getReactiveRuleQuery(rule)!;
502
+ const docs = query.type === 'asn'
503
+ ? await IpIntelligenceDoc.findByAsn(query.asn)
504
+ : await IpIntelligenceDoc.findByOrganization(query.organization, query.matchMode);
505
+ for (const doc of docs) {
506
+ const documentId = (doc as IpIntelligenceDoc & { _id?: unknown })._id;
507
+ const candidateKey = documentId === undefined
508
+ ? `ip:${doc.ipAddress}`
509
+ : `id:${String(documentId)}`;
510
+ candidateDocs.set(candidateKey, doc);
511
+ }
512
+ }
513
+
514
+ for (const rule of reactiveRules) {
515
+ for (const doc of candidateDocs.values()) {
324
516
  if (!this.ruleMatchesIntelligence(rule, doc)) continue;
325
517
  const networkEntries = this.normalizeNetworkEntryList([
326
518
  ...(doc.networkCidrs || []),
@@ -336,12 +528,45 @@ export class SecurityPolicyManager {
336
528
  }
337
529
  }
338
530
 
531
+ return this.compiledPolicyFromSets(blockedIps, blockedCidrs);
532
+ }
533
+
534
+ private compiledPolicyFromSets(
535
+ blockedIps: Set<string>,
536
+ blockedCidrs: Set<string>,
537
+ ): ISecurityCompiledPolicy {
339
538
  return {
340
539
  blockedIps: [...blockedIps].sort(),
341
540
  blockedCidrs: [...blockedCidrs].sort(),
342
541
  };
343
542
  }
344
543
 
544
+ private getReactiveRuleQuery(rule: SecurityBlockRuleDoc):
545
+ | { type: 'asn'; asn: number }
546
+ | { type: 'organization'; organization: string; matchMode: TSecurityBlockRuleMatchMode }
547
+ | undefined {
548
+ const value = rule.value.trim();
549
+ if (!value) return undefined;
550
+
551
+ if (rule.type === 'asn') {
552
+ const normalizedAsn = value.replace(/^as/i, '');
553
+ if (!/^\d+$/.test(normalizedAsn)) return undefined;
554
+ const asn = Number(normalizedAsn);
555
+ if (!Number.isSafeInteger(asn) || String(asn) !== normalizedAsn) return undefined;
556
+ return { type: 'asn', asn };
557
+ }
558
+
559
+ if (rule.type === 'organization') {
560
+ return {
561
+ type: 'organization',
562
+ organization: value,
563
+ matchMode: rule.matchMode === 'exact' ? 'exact' : 'contains',
564
+ };
565
+ }
566
+
567
+ return undefined;
568
+ }
569
+
345
570
  public async compileSmartProxyPolicy(): Promise<ISecurityCompiledPolicy> {
346
571
  return await this.compilePolicy();
347
572
  }
@@ -385,10 +610,10 @@ export class SecurityPolicyManager {
385
610
 
386
611
  private normalizeIp(ipAddress: string): string | undefined {
387
612
  const ip = ipAddress.trim();
388
- if (ip.startsWith('::ffff:')) {
389
- return ip.slice('::ffff:'.length);
390
- }
391
- 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;
392
617
  }
393
618
 
394
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.3',
6
+ version: '17.1.5',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }