@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.
- package/deno.json +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +8 -2
- package/dist_ts/db/documents/classes.ip-intelligence.doc.d.ts +10 -0
- package/dist_ts/db/documents/classes.ip-intelligence.doc.js +63 -1
- package/dist_ts/dns/classes.dns-server-runtime.d.ts +1 -0
- package/dist_ts/dns/classes.dns-server-runtime.js +8 -4
- package/dist_ts/email/classes.email-domain.manager.js +27 -15
- package/dist_ts/email/classes.mail-dns-sync.d.ts +1 -2
- package/dist_ts/email/classes.mail-dns-sync.js +58 -31
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/email/mail-dns-status.d.ts +5 -0
- package/dist_ts/email/mail-dns-status.js +31 -0
- package/dist_ts/security/classes.security-policy-manager.d.ts +17 -0
- package/dist_ts/security/classes.security-policy-manager.js +171 -26
- package/dist_ts_migrations/index.js +270 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +2 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +8 -1
- package/ts/db/documents/classes.ip-intelligence.doc.ts +75 -0
- package/ts/dns/classes.dns-server-runtime.ts +9 -3
- package/ts/email/classes.email-domain.manager.ts +23 -16
- package/ts/email/classes.mail-dns-sync.ts +67 -37
- package/ts/email/index.ts +1 -0
- package/ts/email/mail-dns-status.ts +35 -0
- package/ts/security/classes.security-policy-manager.ts +193 -26
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -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 =
|
|
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 (
|
|
126
|
-
doc
|
|
127
|
-
doc.
|
|
128
|
-
doc.
|
|
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!,
|
|
151
|
-
:
|
|
152
|
-
|
|
153
|
-
let
|
|
154
|
-
if (options.ipAddresses
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
163
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return plugins.net.isIP(
|
|
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 {
|