@serve.zone/dcrouter 17.1.4 → 17.1.6
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_serve/bundle.js +559 -559
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +9 -3
- 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/dns/manager.dns.js +23 -6
- package/dist_ts/email/classes.email-domain.manager.js +27 -15
- package/dist_ts/email/classes.mail-dns-sync.d.ts +19 -2
- package/dist_ts/email/classes.mail-dns-sync.js +158 -32
- 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/monitoring/classes.metricscache.d.ts +2 -0
- package/dist_ts/monitoring/classes.metricscache.js +28 -6
- package/dist_ts/monitoring/classes.metricsmanager.d.ts +10 -0
- package/dist_ts/monitoring/classes.metricsmanager.js +99 -31
- package/dist_ts/opsserver/handlers/security.handler.js +24 -6
- package/dist_ts/opsserver/handlers/stats.handler.js +2 -3
- package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -2
- 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_interfaces/requests/stats.d.ts +7 -0
- package/dist_ts_migrations/index.js +395 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/network.js +11 -2
- package/dist_ts_web/appstate/ui.js +1 -8
- package/package.json +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +11 -2
- package/ts/db/documents/classes.ip-intelligence.doc.ts +75 -0
- package/ts/dns/classes.dns-server-runtime.ts +9 -3
- package/ts/dns/manager.dns.ts +22 -5
- package/ts/email/classes.email-domain.manager.ts +23 -16
- package/ts/email/classes.mail-dns-sync.ts +172 -38
- package/ts/email/index.ts +1 -0
- package/ts/email/mail-dns-status.ts +35 -0
- package/ts/monitoring/classes.metricscache.ts +36 -8
- package/ts/monitoring/classes.metricsmanager.ts +113 -32
- package/ts/opsserver/handlers/security.handler.ts +35 -6
- package/ts/opsserver/handlers/stats.handler.ts +1 -2
- package/ts/remoteingress/classes.hub-lifecycle.ts +3 -1
- package/ts/security/classes.security-policy-manager.ts +193 -26
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/network.ts +12 -1
- package/ts_web/appstate/ui.ts +0 -9
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
MailEdgeEligibility,
|
|
19
19
|
ReleasedRemoteIngressEgressIdentitySource,
|
|
20
20
|
type IEligibleMailEdge,
|
|
21
|
+
type IMailEdgeEligibilityResult,
|
|
21
22
|
} from './classes.mail-edge-eligibility.js';
|
|
22
23
|
import {
|
|
23
24
|
evaluateMailTxtIntent,
|
|
@@ -27,14 +28,17 @@ import {
|
|
|
27
28
|
applyMailDnsVerificationResult,
|
|
28
29
|
reconcileMailDnsSingleton,
|
|
29
30
|
} from './reconcile-mail-dns-singleton.js';
|
|
31
|
+
import { projectMailDnsIntentAggregateStatus } from './mail-dns-status.js';
|
|
30
32
|
|
|
31
33
|
export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
|
|
32
34
|
export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
|
|
33
35
|
export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
34
36
|
export const MAIL_DNS_MAX_RETRY_INTERVAL_MS = 60 * 60_000;
|
|
37
|
+
export const MAIL_DNS_EDGE_ELIGIBILITY_DEBOUNCE_MS = 250;
|
|
38
|
+
export const MAIL_DNS_EDGE_ELIGIBILITY_COOLDOWN_MS = 5_000;
|
|
35
39
|
export const MAIL_DNS_PUBLIC_RESOLVER_OPTIONS = {
|
|
36
|
-
strategy: '
|
|
37
|
-
allowDohFallback:
|
|
40
|
+
strategy: 'prefer-udp' as const,
|
|
41
|
+
allowDohFallback: true,
|
|
38
42
|
timeoutMs: 5_000,
|
|
39
43
|
};
|
|
40
44
|
|
|
@@ -65,6 +69,28 @@ function normalizeHostname(value: string): string {
|
|
|
65
69
|
return value.trim().toLowerCase().replace(/\.$/, '');
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
function mailEdgeEligibilitySignature(result: IMailEdgeEligibilityResult): string {
|
|
73
|
+
const edges = result.edges.map((candidate) => {
|
|
74
|
+
const identities = candidate.identities.map((identity) => ({
|
|
75
|
+
family: identity.family,
|
|
76
|
+
address: identity.address,
|
|
77
|
+
heloHostname: normalizeHostname(identity.heloHostname),
|
|
78
|
+
proof: identity.proof,
|
|
79
|
+
}));
|
|
80
|
+
identities.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
81
|
+
return {
|
|
82
|
+
id: candidate.edge.id,
|
|
83
|
+
hostname: normalizeHostname(candidate.hostname),
|
|
84
|
+
tags: [...(candidate.edge.tags || [])].sort(),
|
|
85
|
+
publicIp: candidate.edge.publicIp || '',
|
|
86
|
+
publicIpV6: candidate.edge.publicIpV6 || '',
|
|
87
|
+
identities,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
edges.sort((left, right) => left.id.localeCompare(right.id));
|
|
91
|
+
return JSON.stringify({ supported: result.supported, edges });
|
|
92
|
+
}
|
|
93
|
+
|
|
68
94
|
function normalizeMx(value: string): string {
|
|
69
95
|
const [priority, exchange = ''] = value.trim().split(/\s+/);
|
|
70
96
|
return `${Number(priority)} ${normalizeHostname(exchange)}`;
|
|
@@ -175,7 +201,25 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
|
|
|
175
201
|
};
|
|
176
202
|
}
|
|
177
203
|
if (result.status === 'missing') {
|
|
178
|
-
|
|
204
|
+
const evidence = result.attempts.map((attempt) => {
|
|
205
|
+
if (attempt.status === 'missing') {
|
|
206
|
+
return `${attempt.transport}:missing:${attempt.missingReason}`;
|
|
207
|
+
}
|
|
208
|
+
if (attempt.status === 'error') {
|
|
209
|
+
return `${attempt.transport}:error:${attempt.error.code}`;
|
|
210
|
+
}
|
|
211
|
+
return `${attempt.transport}:success`;
|
|
212
|
+
}).join(', ');
|
|
213
|
+
const corroboratedMissing = (
|
|
214
|
+
result.attempts.length === 2
|
|
215
|
+
&& result.attempts[0].transport === 'udp'
|
|
216
|
+
&& result.attempts[0].status === 'missing'
|
|
217
|
+
&& result.attempts[1].transport === 'doh'
|
|
218
|
+
&& result.attempts[1].status === 'missing'
|
|
219
|
+
);
|
|
220
|
+
return corroboratedMissing
|
|
221
|
+
? { status: 'missing', reason: `${result.missingReason}; attempts=${evidence}` }
|
|
222
|
+
: { status: 'error', reason: `unconfirmed ${result.missingReason}; attempts=${evidence || 'none'}` };
|
|
179
223
|
}
|
|
180
224
|
return {
|
|
181
225
|
status: 'error',
|
|
@@ -190,7 +234,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
|
|
|
190
234
|
return { valid: false, outcome: 'lookup-error', reason: `DNS lookup failed: ${query.reason}` };
|
|
191
235
|
}
|
|
192
236
|
if (query.status === 'missing') {
|
|
193
|
-
return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing` };
|
|
237
|
+
return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing (${query.reason})` };
|
|
194
238
|
}
|
|
195
239
|
const values = query.values;
|
|
196
240
|
if (intent.type === 'MX') {
|
|
@@ -255,7 +299,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
|
|
|
255
299
|
return { valid: false, outcome: 'lookup-error', reason: `MX lookup failed: ${query.reason}` };
|
|
256
300
|
}
|
|
257
301
|
if (query.status === 'missing') {
|
|
258
|
-
return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing` };
|
|
302
|
+
return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing (${query.reason})` };
|
|
259
303
|
}
|
|
260
304
|
return evaluateMailMxValues(intents, query.values, previousValues);
|
|
261
305
|
} catch (error: unknown) {
|
|
@@ -319,6 +363,12 @@ interface IPlannedDomain {
|
|
|
319
363
|
selectorCorrectSigningSupported: boolean;
|
|
320
364
|
}
|
|
321
365
|
|
|
366
|
+
interface IZoneReconciliationResult {
|
|
367
|
+
errors: Map<string, IEmailDomainOperationError>;
|
|
368
|
+
provenProviderZoneIds: Set<string>;
|
|
369
|
+
readyZoneIds: Set<string>;
|
|
370
|
+
}
|
|
371
|
+
|
|
322
372
|
/**
|
|
323
373
|
* Sole owner of managed mail DNS. API, topology, timer, and startup events only
|
|
324
374
|
* mark this globally serialized reconciler dirty.
|
|
@@ -334,6 +384,13 @@ export class MailDnsSync {
|
|
|
334
384
|
private forceAll = false;
|
|
335
385
|
private forcedDomainIds = new Set<string>();
|
|
336
386
|
private edgeEligibility: MailEdgeEligibility;
|
|
387
|
+
private edgeEligibilitySignature?: string;
|
|
388
|
+
private edgeEligibilityCheckTimer?: ReturnType<typeof setTimeout>;
|
|
389
|
+
private edgeEligibilityCheckPromise?: Promise<void>;
|
|
390
|
+
private pendingEdgeEligibilityReason?: string;
|
|
391
|
+
private lastEdgeEligibilitySyncAt = 0;
|
|
392
|
+
private edgeEligibilityDebounceMs = MAIL_DNS_EDGE_ELIGIBILITY_DEBOUNCE_MS;
|
|
393
|
+
private edgeEligibilityCooldownMs = MAIL_DNS_EDGE_ELIGIBILITY_COOLDOWN_MS;
|
|
337
394
|
|
|
338
395
|
constructor(
|
|
339
396
|
private dcRouterRef: DcRouter,
|
|
@@ -352,12 +409,18 @@ export class MailDnsSync {
|
|
|
352
409
|
}
|
|
353
410
|
this.started = true;
|
|
354
411
|
this.stopped = false;
|
|
412
|
+
this.edgeEligibilitySignature = undefined;
|
|
413
|
+
this.pendingEdgeEligibilityReason = undefined;
|
|
414
|
+
this.lastEdgeEligibilitySyncAt = 0;
|
|
355
415
|
this.queueRequest(undefined, true);
|
|
356
416
|
try {
|
|
357
417
|
await this.kick('startup');
|
|
358
418
|
} catch (error: unknown) {
|
|
359
419
|
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
360
420
|
this.retryTimer = undefined;
|
|
421
|
+
if (this.edgeEligibilityCheckTimer) clearTimeout(this.edgeEligibilityCheckTimer);
|
|
422
|
+
this.edgeEligibilityCheckTimer = undefined;
|
|
423
|
+
this.pendingEdgeEligibilityReason = undefined;
|
|
361
424
|
this.started = false;
|
|
362
425
|
this.dirty = false;
|
|
363
426
|
throw error;
|
|
@@ -372,6 +435,12 @@ export class MailDnsSync {
|
|
|
372
435
|
clearTimeout(this.retryTimer);
|
|
373
436
|
this.retryTimer = undefined;
|
|
374
437
|
}
|
|
438
|
+
if (this.edgeEligibilityCheckTimer) {
|
|
439
|
+
clearTimeout(this.edgeEligibilityCheckTimer);
|
|
440
|
+
this.edgeEligibilityCheckTimer = undefined;
|
|
441
|
+
}
|
|
442
|
+
this.pendingEdgeEligibilityReason = undefined;
|
|
443
|
+
await this.edgeEligibilityCheckPromise?.catch(() => undefined);
|
|
375
444
|
await this.runPromise?.catch(() => undefined);
|
|
376
445
|
this.verifier.destroy?.();
|
|
377
446
|
}
|
|
@@ -385,6 +454,65 @@ export class MailDnsSync {
|
|
|
385
454
|
});
|
|
386
455
|
}
|
|
387
456
|
|
|
457
|
+
/**
|
|
458
|
+
* RemoteIngress topology events are advisory. Resolve the actual mail-edge
|
|
459
|
+
* eligibility snapshot and only reconcile DNS when its semantic shape
|
|
460
|
+
* changes. Volatile heartbeat/observation timestamps never enter the
|
|
461
|
+
* signature.
|
|
462
|
+
*/
|
|
463
|
+
public requestEdgeEligibilityCheck(reason: string): void {
|
|
464
|
+
if (this.stopped || !this.started) return;
|
|
465
|
+
this.pendingEdgeEligibilityReason = reason;
|
|
466
|
+
if (this.edgeEligibilityCheckTimer || this.edgeEligibilityCheckPromise) return;
|
|
467
|
+
this.scheduleEdgeEligibilityCheck();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private scheduleEdgeEligibilityCheck(): void {
|
|
471
|
+
if (this.stopped || !this.started || !this.pendingEdgeEligibilityReason) return;
|
|
472
|
+
const cooldownRemaining = Math.max(
|
|
473
|
+
0,
|
|
474
|
+
this.lastEdgeEligibilitySyncAt + this.edgeEligibilityCooldownMs - Date.now(),
|
|
475
|
+
);
|
|
476
|
+
const delay = Math.max(this.edgeEligibilityDebounceMs, cooldownRemaining);
|
|
477
|
+
this.edgeEligibilityCheckTimer = setTimeout(() => {
|
|
478
|
+
this.edgeEligibilityCheckTimer = undefined;
|
|
479
|
+
this.runEdgeEligibilityCheck().catch((error: unknown) => {
|
|
480
|
+
logger.log('error', `MailDnsSync edge eligibility check failed: ${(error as Error).message}`);
|
|
481
|
+
});
|
|
482
|
+
}, delay);
|
|
483
|
+
this.edgeEligibilityCheckTimer.unref?.();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private async runEdgeEligibilityCheck(): Promise<void> {
|
|
487
|
+
if (this.edgeEligibilityCheckPromise) return await this.edgeEligibilityCheckPromise;
|
|
488
|
+
const reason = this.pendingEdgeEligibilityReason;
|
|
489
|
+
this.pendingEdgeEligibilityReason = undefined;
|
|
490
|
+
if (!reason || this.stopped || !this.started) return;
|
|
491
|
+
|
|
492
|
+
this.edgeEligibilityCheckPromise = (async () => {
|
|
493
|
+
const result = await this.edgeEligibility.resolveForDomain();
|
|
494
|
+
const nextSignature = mailEdgeEligibilitySignature(result);
|
|
495
|
+
const previousSignature = this.edgeEligibilitySignature;
|
|
496
|
+
this.edgeEligibilitySignature = nextSignature;
|
|
497
|
+
if (
|
|
498
|
+
previousSignature === undefined
|
|
499
|
+
|| previousSignature === nextSignature
|
|
500
|
+
|| this.stopped
|
|
501
|
+
|| !this.started
|
|
502
|
+
) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
this.lastEdgeEligibilitySyncAt = Date.now();
|
|
506
|
+
this.requestSync(reason);
|
|
507
|
+
})().finally(() => {
|
|
508
|
+
this.edgeEligibilityCheckPromise = undefined;
|
|
509
|
+
if (this.pendingEdgeEligibilityReason && !this.stopped && this.started) {
|
|
510
|
+
this.scheduleEdgeEligibilityCheck();
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
return await this.edgeEligibilityCheckPromise;
|
|
514
|
+
}
|
|
515
|
+
|
|
388
516
|
/** Coalescing entry point used by API actions that need the durable outcome. */
|
|
389
517
|
public async sync(reason: string, domainIds?: string[]): Promise<void> {
|
|
390
518
|
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
@@ -477,7 +605,8 @@ export class MailDnsSync {
|
|
|
477
605
|
if (docs.length === 0) return;
|
|
478
606
|
|
|
479
607
|
// Refresh the broad operational status independently from per-domain pins.
|
|
480
|
-
await this.edgeEligibility.resolveForDomain();
|
|
608
|
+
const broadEligibility = await this.edgeEligibility.resolveForDomain();
|
|
609
|
+
this.edgeEligibilitySignature = mailEdgeEligibilitySignature(broadEligibility);
|
|
481
610
|
const plans: IPlannedDomain[] = [];
|
|
482
611
|
const deletingDocs: EmailDomainDoc[] = [];
|
|
483
612
|
for (const loadedDoc of docs) {
|
|
@@ -520,7 +649,7 @@ export class MailDnsSync {
|
|
|
520
649
|
if (outcome) Object.assign(intent, outcome);
|
|
521
650
|
if (intent.error) errors.push(intent.error);
|
|
522
651
|
}
|
|
523
|
-
await this.validateAndActivate(plan, errors, zoneResult
|
|
652
|
+
await this.validateAndActivate(plan, errors, zoneResult, zones);
|
|
524
653
|
}
|
|
525
654
|
|
|
526
655
|
const cleanupProof = await this.refreshProviderZonesForCleanup(
|
|
@@ -900,12 +1029,10 @@ export class MailDnsSync {
|
|
|
900
1029
|
private async reconcileZones(
|
|
901
1030
|
intents: IEmailDnsRecordIntent[],
|
|
902
1031
|
zones: DomainDoc[],
|
|
903
|
-
): Promise<{
|
|
904
|
-
errors: Map<string, IEmailDomainOperationError>;
|
|
905
|
-
provenProviderZoneIds: Set<string>;
|
|
906
|
-
}> {
|
|
1032
|
+
): Promise<IZoneReconciliationResult> {
|
|
907
1033
|
const errors = new Map<string, IEmailDomainOperationError>();
|
|
908
1034
|
const provenProviderZoneIds = new Set<string>();
|
|
1035
|
+
const readyZoneIds = new Set<string>();
|
|
909
1036
|
const byZone = new Map<string, IEmailDnsRecordIntent[]>();
|
|
910
1037
|
for (const intent of intents) {
|
|
911
1038
|
if (!intent.domainId || intent.error) continue;
|
|
@@ -921,6 +1048,14 @@ export class MailDnsSync {
|
|
|
921
1048
|
const result = await this.dcRouterRef.dnsManager!.syncDomain(zone.id);
|
|
922
1049
|
if (!result.success) throw new Error(result.message || `Failed to refresh ${zone.name}`);
|
|
923
1050
|
provenProviderZoneIds.add(zone.id);
|
|
1051
|
+
readyZoneIds.add(zone.id);
|
|
1052
|
+
}
|
|
1053
|
+
if (
|
|
1054
|
+
zone.source === 'dcrouter'
|
|
1055
|
+
&& this.dcRouterRef.dnsServer
|
|
1056
|
+
&& this.dcRouterRef.dnsManager?.dnsServer === this.dcRouterRef.dnsServer
|
|
1057
|
+
) {
|
|
1058
|
+
readyZoneIds.add(zone.id);
|
|
924
1059
|
}
|
|
925
1060
|
for (const intent of this.sortIntents(zoneIntents)) {
|
|
926
1061
|
await this.reconcileIntent(intent, zoneIntents);
|
|
@@ -939,7 +1074,7 @@ export class MailDnsSync {
|
|
|
939
1074
|
}
|
|
940
1075
|
}
|
|
941
1076
|
}
|
|
942
|
-
return { errors, provenProviderZoneIds };
|
|
1077
|
+
return { errors, provenProviderZoneIds, readyZoneIds };
|
|
943
1078
|
}
|
|
944
1079
|
|
|
945
1080
|
private sortIntents(intents: IEmailDnsRecordIntent[]): IEmailDnsRecordIntent[] {
|
|
@@ -1104,10 +1239,29 @@ export class MailDnsSync {
|
|
|
1104
1239
|
private async validateAndActivate(
|
|
1105
1240
|
plan: IPlannedDomain,
|
|
1106
1241
|
errors: IEmailDomainOperationError[],
|
|
1107
|
-
|
|
1242
|
+
zoneResult: IZoneReconciliationResult,
|
|
1243
|
+
zones: DomainDoc[],
|
|
1108
1244
|
): Promise<void> {
|
|
1245
|
+
for (const intent of plan.intents) {
|
|
1246
|
+
if (intent.error || zoneResult.readyZoneIds.has(intent.domainId)) continue;
|
|
1247
|
+
const zone = zones.find((candidate) => candidate.id === intent.domainId);
|
|
1248
|
+
const providerManaged = zone?.source === 'provider';
|
|
1249
|
+
const outcome: IEmailDomainOperationError = {
|
|
1250
|
+
code: providerManaged ? 'DNS_PROVIDER_ZONE_UNPROVEN' : 'DNS_SERVER_NOT_READY',
|
|
1251
|
+
message: providerManaged
|
|
1252
|
+
? `DNS provider zone ${zone?.name || intent.domainId} was not proven by this reconciliation pass`
|
|
1253
|
+
: `Authoritative DNS runtime is not ready for ${zone?.name || intent.domainId}`,
|
|
1254
|
+
retryable: true,
|
|
1255
|
+
};
|
|
1256
|
+
intent.status = 'pending';
|
|
1257
|
+
intent.verificationOutcome = 'lookup-error';
|
|
1258
|
+
intent.error = { ...outcome, recordKey: intent.key };
|
|
1259
|
+
if (!errors.some((error) => error.code === outcome.code)) errors.push(outcome);
|
|
1260
|
+
}
|
|
1109
1261
|
const verifiable = plan.intents.filter((intent) => (
|
|
1110
|
-
!intent.error
|
|
1262
|
+
!intent.error
|
|
1263
|
+
&& !zoneResult.errors.has(intent.domainId)
|
|
1264
|
+
&& zoneResult.readyZoneIds.has(intent.domainId)
|
|
1111
1265
|
));
|
|
1112
1266
|
const markResult = (intent: IEmailDnsRecordIntent, result: IMailDnsVerificationResult) => {
|
|
1113
1267
|
const outcome = result.outcome || (result.valid ? 'valid' : 'propagation');
|
|
@@ -1265,13 +1419,13 @@ export class MailDnsSync {
|
|
|
1265
1419
|
&& plan.revision.mxSlots.length > 0;
|
|
1266
1420
|
plan.doc.dnsStatus = {
|
|
1267
1421
|
mx: mailPublicationReady
|
|
1268
|
-
?
|
|
1422
|
+
? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'MX')
|
|
1269
1423
|
: 'unchecked',
|
|
1270
1424
|
spf: mailPublicationReady
|
|
1271
|
-
?
|
|
1425
|
+
? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1'))
|
|
1272
1426
|
: 'unchecked',
|
|
1273
|
-
dkim:
|
|
1274
|
-
dmarc:
|
|
1427
|
+
dkim: projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
|
|
1428
|
+
dmarc: projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
|
|
1275
1429
|
lastCheckedAt: now,
|
|
1276
1430
|
};
|
|
1277
1431
|
plan.doc.updatedAt = now;
|
|
@@ -1307,26 +1461,6 @@ export class MailDnsSync {
|
|
|
1307
1461
|
doc.dkim.lastRotatedAt = now;
|
|
1308
1462
|
}
|
|
1309
1463
|
|
|
1310
|
-
private statusForIntent(
|
|
1311
|
-
intents: IEmailDnsRecordIntent[],
|
|
1312
|
-
predicate: (intent: IEmailDnsRecordIntent) => boolean,
|
|
1313
|
-
): 'valid' | 'missing' | 'invalid' | 'unchecked' {
|
|
1314
|
-
const selected = intents.filter(predicate);
|
|
1315
|
-
if (selected.length === 0) return 'missing';
|
|
1316
|
-
if (selected.every((intent) => intent.status === 'valid')) return 'valid';
|
|
1317
|
-
if (selected.some((intent) => (
|
|
1318
|
-
intent.verificationOutcome === 'duplicate'
|
|
1319
|
-
|| intent.verificationOutcome === 'conflict'
|
|
1320
|
-
|| intent.verificationOutcome === 'invalid'
|
|
1321
|
-
|| intent.status === 'conflict'
|
|
1322
|
-
|| intent.status === 'failed'
|
|
1323
|
-
))) return 'invalid';
|
|
1324
|
-
if (selected.some((intent) => intent.verificationOutcome === 'lookup-error')) {
|
|
1325
|
-
return 'unchecked';
|
|
1326
|
-
}
|
|
1327
|
-
return 'missing';
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
1464
|
private async refreshProviderZonesForCleanup(
|
|
1331
1465
|
docs: EmailDomainDoc[],
|
|
1332
1466
|
zones: DomainDoc[],
|
package/ts/email/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './classes.email-domain.manager.js';
|
|
|
3
3
|
export * from './classes.email-route-builder.js';
|
|
4
4
|
export * from './classes.mail-dns-sync.js';
|
|
5
5
|
export * from './mail-dns-txt-policy.js';
|
|
6
|
+
export * from './mail-dns-status.js';
|
|
6
7
|
export * from './reconcile-mail-dns-singleton.js';
|
|
7
8
|
export * from './classes.mail-egress-coordinator.js';
|
|
8
9
|
export * from './classes.mail-edge-eligibility.js';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IEmailDnsRecordIntent,
|
|
3
|
+
TDnsRecordStatus,
|
|
4
|
+
} from '../../ts_interfaces/data/email-domain.js';
|
|
5
|
+
|
|
6
|
+
/** Project one durable reconciliation intent into the operator-facing DNS status. */
|
|
7
|
+
export function projectMailDnsIntentStatus(
|
|
8
|
+
intent: IEmailDnsRecordIntent | undefined,
|
|
9
|
+
): TDnsRecordStatus {
|
|
10
|
+
if (!intent) return 'unchecked';
|
|
11
|
+
if (
|
|
12
|
+
intent.verificationOutcome === 'duplicate'
|
|
13
|
+
|| intent.verificationOutcome === 'conflict'
|
|
14
|
+
|| intent.verificationOutcome === 'invalid'
|
|
15
|
+
|| intent.status === 'conflict'
|
|
16
|
+
|| intent.status === 'failed'
|
|
17
|
+
) return 'invalid';
|
|
18
|
+
if (intent.verificationOutcome === 'missing') return 'missing';
|
|
19
|
+
if (intent.verificationOutcome === 'valid' && intent.status === 'valid') return 'valid';
|
|
20
|
+
return 'unchecked';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Aggregate matching intents with deterministic operator-facing precedence. */
|
|
24
|
+
export function projectMailDnsIntentAggregateStatus(
|
|
25
|
+
intents: IEmailDnsRecordIntent[],
|
|
26
|
+
predicate: (intent: IEmailDnsRecordIntent) => boolean,
|
|
27
|
+
): TDnsRecordStatus {
|
|
28
|
+
const selected = intents.filter(predicate);
|
|
29
|
+
if (selected.length === 0) return 'unchecked';
|
|
30
|
+
const statuses = selected.map(projectMailDnsIntentStatus);
|
|
31
|
+
if (statuses.includes('invalid')) return 'invalid';
|
|
32
|
+
if (statuses.includes('missing')) return 'missing';
|
|
33
|
+
if (statuses.includes('unchecked')) return 'unchecked';
|
|
34
|
+
return 'valid';
|
|
35
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export interface ICacheEntry<T> {
|
|
2
2
|
data: T;
|
|
3
3
|
timestamp: number;
|
|
4
|
+
ttl: number;
|
|
4
5
|
}
|
|
5
6
|
|
|
6
7
|
export class MetricsCache {
|
|
7
8
|
private cache = new Map<string, ICacheEntry<any>>();
|
|
9
|
+
private inFlight = new Map<string, Promise<any>>();
|
|
8
10
|
private readonly defaultTTL: number;
|
|
9
11
|
|
|
10
12
|
constructor(defaultTTL: number = 500) {
|
|
@@ -19,20 +21,44 @@ export class MetricsCache {
|
|
|
19
21
|
const now = Date.now();
|
|
20
22
|
const actualTTL = ttl ?? this.defaultTTL;
|
|
21
23
|
|
|
22
|
-
if (cached && (now - cached.timestamp) <
|
|
24
|
+
if (cached && (now - cached.timestamp) < cached.ttl) {
|
|
23
25
|
return cached.data;
|
|
24
26
|
}
|
|
27
|
+
|
|
28
|
+
if (cached) {
|
|
29
|
+
this.cache.delete(key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pending = this.inFlight.get(key) as Promise<T> | undefined;
|
|
33
|
+
if (pending) {
|
|
34
|
+
return pending;
|
|
35
|
+
}
|
|
25
36
|
|
|
26
37
|
const result = computeFn();
|
|
27
38
|
|
|
28
39
|
// Handle both sync and async compute functions
|
|
29
40
|
if (result instanceof Promise) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
let inFlightPromise: Promise<T>;
|
|
42
|
+
inFlightPromise = result.then(
|
|
43
|
+
(data) => {
|
|
44
|
+
if (this.inFlight.get(key) === inFlightPromise) {
|
|
45
|
+
this.inFlight.delete(key);
|
|
46
|
+
this.cache.set(key, { data, timestamp: Date.now(), ttl: actualTTL });
|
|
47
|
+
}
|
|
48
|
+
return data;
|
|
49
|
+
},
|
|
50
|
+
(error: unknown) => {
|
|
51
|
+
if (this.inFlight.get(key) === inFlightPromise) {
|
|
52
|
+
this.inFlight.delete(key);
|
|
53
|
+
this.cache.delete(key);
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
this.inFlight.set(key, inFlightPromise);
|
|
59
|
+
return inFlightPromise;
|
|
34
60
|
} else {
|
|
35
|
-
this.cache.set(key, { data: result, timestamp: now });
|
|
61
|
+
this.cache.set(key, { data: result, timestamp: now, ttl: actualTTL });
|
|
36
62
|
return result;
|
|
37
63
|
}
|
|
38
64
|
}
|
|
@@ -42,6 +68,7 @@ export class MetricsCache {
|
|
|
42
68
|
*/
|
|
43
69
|
public invalidate(key: string): void {
|
|
44
70
|
this.cache.delete(key);
|
|
71
|
+
this.inFlight.delete(key);
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
/**
|
|
@@ -49,6 +76,7 @@ export class MetricsCache {
|
|
|
49
76
|
*/
|
|
50
77
|
public clear(): void {
|
|
51
78
|
this.cache.clear();
|
|
79
|
+
this.inFlight.clear();
|
|
52
80
|
}
|
|
53
81
|
|
|
54
82
|
/**
|
|
@@ -67,9 +95,9 @@ export class MetricsCache {
|
|
|
67
95
|
public cleanup(): void {
|
|
68
96
|
const now = Date.now();
|
|
69
97
|
for (const [key, entry] of this.cache.entries()) {
|
|
70
|
-
if (now - entry.timestamp >
|
|
98
|
+
if (now - entry.timestamp > entry.ttl) {
|
|
71
99
|
this.cache.delete(key);
|
|
72
100
|
}
|
|
73
101
|
}
|
|
74
102
|
}
|
|
75
|
-
}
|
|
103
|
+
}
|