@serve.zone/dcrouter 18.6.2 → 18.7.1
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 +4 -1
- package/dist_ts/dns/classes.dns-server-runtime.js +3 -3
- package/dist_ts/dns/manager.dns.d.ts +16 -0
- package/dist_ts/dns/manager.dns.js +193 -40
- package/dist_ts/dns/providers/cloudflare.provider.d.ts +5 -0
- package/dist_ts/dns/providers/cloudflare.provider.js +78 -23
- package/dist_ts/dns/providers/interfaces.d.ts +2 -0
- package/dist_ts/email/classes.email-domain.manager.d.ts +1 -0
- package/dist_ts/email/classes.email-domain.manager.js +36 -8
- package/dist_ts/email/classes.mail-dns-sync.d.ts +8 -0
- package/dist_ts/email/classes.mail-dns-sync.js +239 -55
- package/dist_ts/email/classes.mail-edge-eligibility.js +44 -17
- package/dist_ts/email/classes.mail-egress-coordinator.js +13 -3
- package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +1 -0
- package/dist_ts/email/reconcile-mail-dns-singleton.js +6 -2
- package/dist_ts/opsserver/handlers/dns-provider.handler.js +3 -1
- package/dist_ts/opsserver/handlers/dns-record.handler.js +44 -35
- package/dist_ts/opsserver/handlers/domain.handler.js +13 -2
- package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
- package/dist_ts_interfaces/data/email-domain.d.ts +2 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +4 -4
- package/readme.md +7 -7
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +4 -0
- package/ts/dns/classes.dns-server-runtime.ts +2 -2
- package/ts/dns/manager.dns.ts +235 -35
- package/ts/dns/providers/cloudflare.provider.ts +93 -26
- package/ts/dns/providers/interfaces.ts +3 -0
- package/ts/email/classes.email-domain.manager.ts +38 -6
- package/ts/email/classes.mail-dns-sync.ts +246 -53
- package/ts/email/classes.mail-edge-eligibility.ts +53 -15
- package/ts/email/classes.mail-egress-coordinator.ts +18 -4
- package/ts/email/reconcile-mail-dns-singleton.ts +8 -1
- package/ts/opsserver/handlers/dns-provider.handler.ts +2 -0
- package/ts/opsserver/handlers/dns-record.handler.ts +58 -49
- package/ts/opsserver/handlers/domain.handler.ts +11 -1
- package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -34,6 +34,7 @@ export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
|
|
|
34
34
|
export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
|
|
35
35
|
export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
36
36
|
export const MAIL_DNS_MAX_RETRY_INTERVAL_MS = 60 * 60_000;
|
|
37
|
+
export const MAIL_DNS_RECONCILIATION_INTERVAL_MS = 5 * 60_000;
|
|
37
38
|
export const MAIL_DNS_EDGE_ELIGIBILITY_DEBOUNCE_MS = 250;
|
|
38
39
|
export const MAIL_DNS_EDGE_ELIGIBILITY_COOLDOWN_MS = 5_000;
|
|
39
40
|
export const MAIL_DNS_PUBLIC_RESOLVER_OPTIONS = {
|
|
@@ -379,6 +380,9 @@ export class MailDnsSync {
|
|
|
379
380
|
private dirty = false;
|
|
380
381
|
private runPromise?: Promise<void>;
|
|
381
382
|
private retryTimer?: ReturnType<typeof setTimeout>;
|
|
383
|
+
private schedulePromises = new Set<Promise<void>>();
|
|
384
|
+
private scheduleGeneration = 0;
|
|
385
|
+
private lastScheduledReconciliationAt = 0;
|
|
382
386
|
private requestedGeneration = 0;
|
|
383
387
|
private completedGeneration = 0;
|
|
384
388
|
private forceAll = false;
|
|
@@ -416,13 +420,19 @@ export class MailDnsSync {
|
|
|
416
420
|
try {
|
|
417
421
|
await this.kick('startup');
|
|
418
422
|
} catch (error: unknown) {
|
|
423
|
+
this.stopped = true;
|
|
424
|
+
this.started = false;
|
|
425
|
+
this.dirty = false;
|
|
426
|
+
this.scheduleGeneration++;
|
|
419
427
|
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
420
428
|
this.retryTimer = undefined;
|
|
421
429
|
if (this.edgeEligibilityCheckTimer) clearTimeout(this.edgeEligibilityCheckTimer);
|
|
422
430
|
this.edgeEligibilityCheckTimer = undefined;
|
|
423
431
|
this.pendingEdgeEligibilityReason = undefined;
|
|
424
|
-
this.
|
|
425
|
-
this.
|
|
432
|
+
await this.edgeEligibilityCheckPromise?.catch(() => undefined);
|
|
433
|
+
await this.runPromise?.catch(() => undefined);
|
|
434
|
+
await Promise.all([...this.schedulePromises]);
|
|
435
|
+
this.verifier.destroy?.();
|
|
426
436
|
throw error;
|
|
427
437
|
}
|
|
428
438
|
}
|
|
@@ -431,6 +441,7 @@ export class MailDnsSync {
|
|
|
431
441
|
this.stopped = true;
|
|
432
442
|
this.started = false;
|
|
433
443
|
this.dirty = false;
|
|
444
|
+
this.scheduleGeneration++;
|
|
434
445
|
if (this.retryTimer) {
|
|
435
446
|
clearTimeout(this.retryTimer);
|
|
436
447
|
this.retryTimer = undefined;
|
|
@@ -442,6 +453,7 @@ export class MailDnsSync {
|
|
|
442
453
|
this.pendingEdgeEligibilityReason = undefined;
|
|
443
454
|
await this.edgeEligibilityCheckPromise?.catch(() => undefined);
|
|
444
455
|
await this.runPromise?.catch(() => undefined);
|
|
456
|
+
await Promise.all([...this.schedulePromises]);
|
|
445
457
|
this.verifier.destroy?.();
|
|
446
458
|
}
|
|
447
459
|
|
|
@@ -474,13 +486,14 @@ export class MailDnsSync {
|
|
|
474
486
|
this.lastEdgeEligibilitySyncAt + this.edgeEligibilityCooldownMs - Date.now(),
|
|
475
487
|
);
|
|
476
488
|
const delay = Math.max(this.edgeEligibilityDebounceMs, cooldownRemaining);
|
|
477
|
-
|
|
489
|
+
const timer = setTimeout(() => {
|
|
478
490
|
this.edgeEligibilityCheckTimer = undefined;
|
|
479
491
|
this.runEdgeEligibilityCheck().catch((error: unknown) => {
|
|
480
492
|
logger.log('error', `MailDnsSync edge eligibility check failed: ${(error as Error).message}`);
|
|
481
493
|
});
|
|
482
|
-
}, delay);
|
|
483
|
-
this.edgeEligibilityCheckTimer
|
|
494
|
+
}, delay) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
|
495
|
+
this.edgeEligibilityCheckTimer = timer;
|
|
496
|
+
timer.unref?.();
|
|
484
497
|
}
|
|
485
498
|
|
|
486
499
|
private async runEdgeEligibilityCheck(): Promise<void> {
|
|
@@ -518,8 +531,10 @@ export class MailDnsSync {
|
|
|
518
531
|
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
519
532
|
const ticket = this.queueRequest(domainIds, domainIds === undefined);
|
|
520
533
|
while (this.completedGeneration < ticket) {
|
|
534
|
+
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
521
535
|
await this.kick(reason);
|
|
522
536
|
}
|
|
537
|
+
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
523
538
|
}
|
|
524
539
|
|
|
525
540
|
public async syncDomain(domainId: string, reason: string): Promise<{
|
|
@@ -531,8 +546,10 @@ export class MailDnsSync {
|
|
|
531
546
|
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
532
547
|
const requestedGeneration = this.queueRequest([domainId], false);
|
|
533
548
|
while (this.completedGeneration < requestedGeneration) {
|
|
549
|
+
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
534
550
|
await this.kick(reason);
|
|
535
551
|
}
|
|
552
|
+
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
536
553
|
const domain = await EmailDomainDoc.findById(domainId);
|
|
537
554
|
return {
|
|
538
555
|
requestedGeneration,
|
|
@@ -551,6 +568,7 @@ export class MailDnsSync {
|
|
|
551
568
|
}
|
|
552
569
|
|
|
553
570
|
private async kick(reason: string): Promise<void> {
|
|
571
|
+
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
554
572
|
if (this.runPromise) return await this.runPromise;
|
|
555
573
|
// Defer execution one microtask so runPromise is assigned before the first
|
|
556
574
|
// awaited provider/eligibility hook can synchronously enqueue another event.
|
|
@@ -561,9 +579,7 @@ export class MailDnsSync {
|
|
|
561
579
|
logger.log('error', `MailDnsSync follow-up failed: ${(error as Error).message}`);
|
|
562
580
|
});
|
|
563
581
|
}
|
|
564
|
-
this.
|
|
565
|
-
logger.log('error', `MailDnsSync scheduling failed: ${(error as Error).message}`);
|
|
566
|
-
});
|
|
582
|
+
this.queueScheduleNextDue();
|
|
567
583
|
});
|
|
568
584
|
return await this.runPromise;
|
|
569
585
|
}
|
|
@@ -593,7 +609,15 @@ export class MailDnsSync {
|
|
|
593
609
|
logger.log('warn', `MailDnsSync (${reason}): DnsManager unavailable; retry intent retained`);
|
|
594
610
|
return;
|
|
595
611
|
}
|
|
612
|
+
await dnsManager.runManagedMailDnsMutationExclusive(
|
|
613
|
+
async () => await this.reconcileExclusive(reason, work),
|
|
614
|
+
);
|
|
615
|
+
}
|
|
596
616
|
|
|
617
|
+
private async reconcileExclusive(
|
|
618
|
+
reason: string,
|
|
619
|
+
work: { forceAll: boolean; forcedDomainIds: Set<string> },
|
|
620
|
+
): Promise<void> {
|
|
597
621
|
const zones = await DomainDoc.findAll();
|
|
598
622
|
const allDocs = await EmailDomainDoc.findAll();
|
|
599
623
|
const now = Date.now();
|
|
@@ -663,8 +687,9 @@ export class MailDnsSync {
|
|
|
663
687
|
new Set(plans.map((plan) => `domain:${plan.doc.id}`).concat(
|
|
664
688
|
deletingDocs.map((doc) => `domain:${doc.id}`),
|
|
665
689
|
)),
|
|
666
|
-
work.forceAll,
|
|
690
|
+
work.forceAll || deletingDocs.length > 0,
|
|
667
691
|
);
|
|
692
|
+
await this.pruneRetiredDkimMaterial(plans.map((plan) => plan.doc));
|
|
668
693
|
await this.finalizeDeletingDomains(deletingDocs, zones);
|
|
669
694
|
await this.dcRouterRef.emailDomainManager?.syncManagedDomainsToRuntime();
|
|
670
695
|
await this.dcRouterRef.workAppMailManager?.applyStoredIdentitiesToRuntime();
|
|
@@ -677,9 +702,17 @@ export class MailDnsSync {
|
|
|
677
702
|
private isDomainDue(doc: EmailDomainDoc, now: number): boolean {
|
|
678
703
|
const retryAt = Date.parse(doc.reconciliation?.retryAt || '');
|
|
679
704
|
if (Number.isFinite(retryAt) && retryAt <= now) return true;
|
|
705
|
+
if (Number.isFinite(retryAt)) return false;
|
|
680
706
|
if (!doc.reconciliation?.lastAttemptAt) return true;
|
|
707
|
+
const lastAttemptAt = Date.parse(doc.reconciliation.lastAttemptAt);
|
|
708
|
+
if (
|
|
709
|
+
Number.isFinite(lastAttemptAt)
|
|
710
|
+
&& lastAttemptAt + MAIL_DNS_RECONCILIATION_INTERVAL_MS <= now
|
|
711
|
+
) return true;
|
|
681
712
|
const rotationDueAt = this.getDkimRotationDueAt(doc, now);
|
|
682
|
-
|
|
713
|
+
if (rotationDueAt !== undefined && rotationDueAt <= now) return true;
|
|
714
|
+
const retirementDueAt = this.getDkimRetirementDueAt(doc);
|
|
715
|
+
return retirementDueAt !== undefined && retirementDueAt <= now;
|
|
683
716
|
}
|
|
684
717
|
|
|
685
718
|
private getDkimRotationDueAt(doc: EmailDomainDoc, now: number): number | undefined {
|
|
@@ -702,29 +735,65 @@ export class MailDnsSync {
|
|
|
702
735
|
return Math.max(dueAt, retryReference + MAIL_DNS_RETRY_INTERVAL_MS);
|
|
703
736
|
}
|
|
704
737
|
|
|
738
|
+
private getDkimRetirementDueAt(doc: EmailDomainDoc): number | undefined {
|
|
739
|
+
const dueTimes = (doc.retiringDkim || [])
|
|
740
|
+
.map((material) => Date.parse(material.retireAfter || ''))
|
|
741
|
+
.filter(Number.isFinite);
|
|
742
|
+
return dueTimes.length > 0 ? Math.min(...dueTimes) : undefined;
|
|
743
|
+
}
|
|
744
|
+
|
|
705
745
|
private async scheduleNextDue(): Promise<void> {
|
|
746
|
+
const generation = ++this.scheduleGeneration;
|
|
706
747
|
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
707
748
|
this.retryTimer = undefined;
|
|
708
749
|
if (!this.started || this.stopped) return;
|
|
709
750
|
const dueTimes: number[] = [];
|
|
710
751
|
const now = Date.now();
|
|
711
|
-
|
|
752
|
+
const docs = await EmailDomainDoc.findAll();
|
|
753
|
+
if (generation !== this.scheduleGeneration || !this.started || this.stopped) return;
|
|
754
|
+
for (const doc of docs) {
|
|
712
755
|
const retryAt = Date.parse(doc.reconciliation?.retryAt || '');
|
|
713
|
-
if (Number.isFinite(retryAt))
|
|
756
|
+
if (Number.isFinite(retryAt)) {
|
|
757
|
+
dueTimes.push(retryAt);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
const lastAttemptAt = Date.parse(doc.reconciliation?.lastAttemptAt || '');
|
|
761
|
+
dueTimes.push(Number.isFinite(lastAttemptAt)
|
|
762
|
+
? lastAttemptAt + MAIL_DNS_RECONCILIATION_INTERVAL_MS
|
|
763
|
+
: now);
|
|
714
764
|
const rotationDueAt = this.getDkimRotationDueAt(doc, now);
|
|
715
765
|
if (rotationDueAt !== undefined) dueTimes.push(rotationDueAt);
|
|
766
|
+
const retirementDueAt = this.getDkimRetirementDueAt(doc);
|
|
767
|
+
if (retirementDueAt !== undefined) dueTimes.push(retirementDueAt);
|
|
716
768
|
}
|
|
717
769
|
if (dueTimes.length === 0) return;
|
|
718
|
-
const
|
|
770
|
+
const nextDueAt = Math.max(
|
|
771
|
+
Math.min(...dueTimes),
|
|
772
|
+
this.lastScheduledReconciliationAt + MAIL_DNS_RECONCILIATION_INTERVAL_MS,
|
|
773
|
+
);
|
|
774
|
+
const delay = Math.max(1, Math.min(2_147_483_647, nextDueAt - now));
|
|
719
775
|
this.retryTimer = setTimeout(() => {
|
|
720
|
-
|
|
721
|
-
this.
|
|
722
|
-
this.
|
|
776
|
+
this.retryTimer = undefined;
|
|
777
|
+
if (this.stopped || !this.started) return;
|
|
778
|
+
this.lastScheduledReconciliationAt = Date.now();
|
|
779
|
+
this.queueRequest(undefined, true);
|
|
780
|
+
this.kick('scheduled desired-state reconciliation').catch((error: unknown) => {
|
|
723
781
|
logger.log('error', `MailDnsSync scheduled retry failed: ${(error as Error).message}`);
|
|
724
782
|
});
|
|
725
783
|
}, delay);
|
|
726
784
|
}
|
|
727
785
|
|
|
786
|
+
private queueScheduleNextDue(): void {
|
|
787
|
+
const schedulePromise = this.scheduleNextDue()
|
|
788
|
+
.catch((error: unknown) => {
|
|
789
|
+
logger.log('error', `MailDnsSync scheduling failed: ${(error as Error).message}`);
|
|
790
|
+
})
|
|
791
|
+
.finally(() => {
|
|
792
|
+
this.schedulePromises.delete(schedulePromise);
|
|
793
|
+
});
|
|
794
|
+
this.schedulePromises.add(schedulePromise);
|
|
795
|
+
}
|
|
796
|
+
|
|
728
797
|
private async buildPlan(doc: EmailDomainDoc, zones: DomainDoc[]): Promise<IPlannedDomain> {
|
|
729
798
|
const now = new Date().toISOString();
|
|
730
799
|
const eligibility = await this.edgeEligibility.resolveForDomain(doc);
|
|
@@ -746,6 +815,12 @@ export class MailDnsSync {
|
|
|
746
815
|
message: eligibility.reason || 'No eligible mail edge is connected',
|
|
747
816
|
retryable: true,
|
|
748
817
|
});
|
|
818
|
+
} else if (eligibility.edges.length < 2) {
|
|
819
|
+
preliminaryErrors.push({
|
|
820
|
+
code: 'INSUFFICIENT_ELIGIBLE_MAIL_EDGES',
|
|
821
|
+
message: 'Two eligible RemoteIngress mail edges are required; direct hub fallback is disabled',
|
|
822
|
+
retryable: true,
|
|
823
|
+
});
|
|
749
824
|
}
|
|
750
825
|
|
|
751
826
|
const activeDkim = this.getActiveDkim(doc);
|
|
@@ -767,19 +842,26 @@ export class MailDnsSync {
|
|
|
767
842
|
});
|
|
768
843
|
}
|
|
769
844
|
|
|
770
|
-
const edgeIdentities = eligibility.edges.map((candidate) => this.toDomainEdgeIdentity(candidate));
|
|
771
845
|
const mxSlots = this.allocateMxSlots(doc, eligibility.edges);
|
|
772
846
|
if (eligibility.edges.length >= 2 && mxSlots.length < 2) {
|
|
773
847
|
preliminaryErrors.push({
|
|
774
848
|
code: 'INSUFFICIENT_DISTINCT_MX_IDENTITIES',
|
|
775
849
|
message: 'At least two eligible mail edges exist but they do not provide two distinct MX hostnames',
|
|
776
|
-
retryable:
|
|
850
|
+
retryable: true,
|
|
777
851
|
});
|
|
778
852
|
}
|
|
853
|
+
const candidatesById = new Map<string, IEligibleMailEdge>(
|
|
854
|
+
eligibility.edges.map((candidate) => [candidate.edge.id, candidate]),
|
|
855
|
+
);
|
|
856
|
+
const selectedEdges: IEligibleMailEdge[] = mxSlots.flatMap((slot) => {
|
|
857
|
+
const candidate = candidatesById.get(slot.edgeId);
|
|
858
|
+
return candidate ? [candidate] : [];
|
|
859
|
+
});
|
|
860
|
+
const edgeIdentities = selectedEdges.map((candidate) => this.toDomainEdgeIdentity(candidate));
|
|
779
861
|
const revisionId = plugins.smartunique.shortId();
|
|
780
862
|
const intents: IEmailDnsRecordIntent[] = [];
|
|
781
863
|
|
|
782
|
-
for (const candidate of
|
|
864
|
+
for (const candidate of selectedEdges) {
|
|
783
865
|
for (const identity of candidate.identities) {
|
|
784
866
|
const type = identity.family === 4 ? 'A' : 'AAAA';
|
|
785
867
|
intents.push(this.intentForName(zones, {
|
|
@@ -794,7 +876,7 @@ export class MailDnsSync {
|
|
|
794
876
|
}
|
|
795
877
|
}
|
|
796
878
|
|
|
797
|
-
if (
|
|
879
|
+
if (mxSlots.length === 2) {
|
|
798
880
|
for (const slot of mxSlots) {
|
|
799
881
|
intents.push(this.intentForName(zones, {
|
|
800
882
|
key: `mail-domain:${doc.id}:mx:${slot.priority}:${slot.edgeId}:${slot.hostname}`,
|
|
@@ -922,13 +1004,7 @@ export class MailDnsSync {
|
|
|
922
1004
|
}
|
|
923
1005
|
|
|
924
1006
|
private allocateMxSlots(doc: EmailDomainDoc, candidates: IEligibleMailEdge[]): IEmailDomainMxSlot[] {
|
|
925
|
-
if (candidates.length
|
|
926
|
-
return [{
|
|
927
|
-
priority: 10,
|
|
928
|
-
edgeId: candidates[0].edge.id,
|
|
929
|
-
hostname: candidates[0].hostname,
|
|
930
|
-
}];
|
|
931
|
-
}
|
|
1007
|
+
if (candidates.length < 2) return [];
|
|
932
1008
|
const byId = new Map(candidates.map((candidate) => [candidate.edge.id, candidate]));
|
|
933
1009
|
const result: IEmailDomainMxSlot[] = [];
|
|
934
1010
|
const usedEdges = new Set<string>();
|
|
@@ -956,7 +1032,9 @@ export class MailDnsSync {
|
|
|
956
1032
|
usedEdges.add(candidate.edge.id);
|
|
957
1033
|
usedHostnames.add(candidate.hostname);
|
|
958
1034
|
}
|
|
959
|
-
return result.
|
|
1035
|
+
return result.length === 2
|
|
1036
|
+
? result.sort((left, right) => left.priority - right.priority)
|
|
1037
|
+
: [];
|
|
960
1038
|
}
|
|
961
1039
|
|
|
962
1040
|
private buildSpfValue(identities: IEmailDomainEdgeIdentity[]): string {
|
|
@@ -975,7 +1053,7 @@ export class MailDnsSync {
|
|
|
975
1053
|
const generation = (previous?.desiredGeneration || 0) + (previousSignature === nextSignature ? 0 : 1);
|
|
976
1054
|
const desiredGeneration = Math.max(generation, 1);
|
|
977
1055
|
plan.revision.generation = desiredGeneration;
|
|
978
|
-
const providerZoneIds = new Set(
|
|
1056
|
+
const providerZoneIds = new Set<string>();
|
|
979
1057
|
const providerZones = new Set(zones.filter((zone) => zone.source === 'provider').map((zone) => zone.id));
|
|
980
1058
|
for (const intent of plan.intents) {
|
|
981
1059
|
if (providerZones.has(intent.domainId)) providerZoneIds.add(intent.domainId);
|
|
@@ -1010,6 +1088,34 @@ export class MailDnsSync {
|
|
|
1010
1088
|
}
|
|
1011
1089
|
|
|
1012
1090
|
private async persistDeletingAttempt(doc: EmailDomainDoc): Promise<void> {
|
|
1091
|
+
const deletingProviderRecordIds = new Set(doc.reconciliation?.deletingProviderRecordIds || []);
|
|
1092
|
+
const deletingRecordKeys = new Set([
|
|
1093
|
+
...(doc.reconciliation?.activeRevision?.recordKeys || []),
|
|
1094
|
+
...(doc.reconciliation?.pendingRevision?.recordKeys || []),
|
|
1095
|
+
]);
|
|
1096
|
+
const retainedTopologyKeys = new Set<string>();
|
|
1097
|
+
for (const otherDoc of await EmailDomainDoc.findAll()) {
|
|
1098
|
+
if (otherDoc.id === doc.id || otherDoc.reconciliation?.lifecycleStatus === 'deleting') continue;
|
|
1099
|
+
for (const intent of otherDoc.reconciliation?.intents || []) retainedTopologyKeys.add(intent.key);
|
|
1100
|
+
for (const key of otherDoc.reconciliation?.activeRevision?.recordKeys || []) retainedTopologyKeys.add(key);
|
|
1101
|
+
for (const key of otherDoc.reconciliation?.pendingRevision?.recordKeys || []) retainedTopologyKeys.add(key);
|
|
1102
|
+
}
|
|
1103
|
+
const hasKnownProviderProvenance = doc.reconciliation?.providerZoneIds !== undefined;
|
|
1104
|
+
const providerZoneIds = new Set(doc.reconciliation?.providerZoneIds || []);
|
|
1105
|
+
for (const record of await DnsRecordDoc.findAll()) {
|
|
1106
|
+
const ownedByDomain = record.managedOwnerId === `domain:${doc.id}`;
|
|
1107
|
+
const unsharedDeletingTopology = record.managedOwnerId?.startsWith('topology:')
|
|
1108
|
+
&& Boolean(record.managedRecordKey && deletingRecordKeys.has(record.managedRecordKey))
|
|
1109
|
+
&& Boolean(record.managedRecordKey && !retainedTopologyKeys.has(record.managedRecordKey));
|
|
1110
|
+
if (
|
|
1111
|
+
record.managedBy === MAIL_DNS_MANAGED_BY
|
|
1112
|
+
&& (ownedByDomain || unsharedDeletingTopology)
|
|
1113
|
+
&& record.providerRecordId?.trim()
|
|
1114
|
+
) {
|
|
1115
|
+
deletingProviderRecordIds.add(record.providerRecordId.trim());
|
|
1116
|
+
if (hasKnownProviderProvenance) providerZoneIds.add(record.domainId);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1013
1119
|
doc.reconciliation = {
|
|
1014
1120
|
...(doc.reconciliation || {
|
|
1015
1121
|
lifecycleStatus: 'deleting',
|
|
@@ -1022,6 +1128,8 @@ export class MailDnsSync {
|
|
|
1022
1128
|
errors: (doc.reconciliation?.errors || []).filter((error) => error.code !== 'DNS_CLEANUP_FAILED'),
|
|
1023
1129
|
lastAttemptAt: new Date().toISOString(),
|
|
1024
1130
|
retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString(),
|
|
1131
|
+
...(hasKnownProviderProvenance ? { providerZoneIds: [...providerZoneIds].sort() } : {}),
|
|
1132
|
+
deletingProviderRecordIds: [...deletingProviderRecordIds].sort(),
|
|
1025
1133
|
};
|
|
1026
1134
|
await doc.save();
|
|
1027
1135
|
}
|
|
@@ -1095,16 +1203,35 @@ export class MailDnsSync {
|
|
|
1095
1203
|
const rrset = records.filter((record) => (
|
|
1096
1204
|
record.name.toLowerCase() === intent.name.toLowerCase() && record.type === intent.type
|
|
1097
1205
|
));
|
|
1206
|
+
const ownedByKey = records.filter((record) => (
|
|
1207
|
+
record.managedBy === MAIL_DNS_MANAGED_BY
|
|
1208
|
+
&& record.managedOwnerId === intent.ownerId
|
|
1209
|
+
&& record.managedRecordKey === intent.key
|
|
1210
|
+
));
|
|
1211
|
+
if (ownedByKey.length > 1) {
|
|
1212
|
+
intent.status = 'conflict';
|
|
1213
|
+
intent.verificationOutcome = 'duplicate';
|
|
1214
|
+
intent.error = {
|
|
1215
|
+
code: 'DNS_MANAGED_KEY_DUPLICATE',
|
|
1216
|
+
message: `Multiple managed DNS records share ownership key ${intent.key}`,
|
|
1217
|
+
recordKey: intent.key,
|
|
1218
|
+
retryable: false,
|
|
1219
|
+
};
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const reconciliationRecords = [...new Map(
|
|
1223
|
+
[...rrset, ...ownedByKey].map((record) => [record.id, record]),
|
|
1224
|
+
).values()];
|
|
1098
1225
|
if (await reconcileMailDnsSingleton({
|
|
1099
1226
|
intent,
|
|
1100
|
-
rrset,
|
|
1227
|
+
rrset: reconciliationRecords,
|
|
1101
1228
|
dnsManager: this.dcRouterRef.dnsManager!,
|
|
1102
1229
|
managedBy: MAIL_DNS_MANAGED_BY,
|
|
1103
1230
|
createdBy: MAIL_DNS_SYNC_CREATED_BY,
|
|
1104
1231
|
evaluate: async (candidate, values) => await this.evaluateTxtRecords(candidate, values),
|
|
1105
1232
|
})) return;
|
|
1106
1233
|
|
|
1107
|
-
const exactOwner =
|
|
1234
|
+
const exactOwner = reconciliationRecords
|
|
1108
1235
|
.filter((record) => (
|
|
1109
1236
|
record.managedBy === MAIL_DNS_MANAGED_BY
|
|
1110
1237
|
&& record.managedOwnerId === intent.ownerId
|
|
@@ -1175,10 +1302,16 @@ export class MailDnsSync {
|
|
|
1175
1302
|
const managed = exactOwner.find((record) => this.valuesEqual(intent.type, record.value, intent.value))
|
|
1176
1303
|
|| exactOwner[0];
|
|
1177
1304
|
if (managed) {
|
|
1178
|
-
if (
|
|
1305
|
+
if (
|
|
1306
|
+
!this.valuesEqual(intent.type, managed.value, intent.value)
|
|
1307
|
+
|| managed.name !== intent.name
|
|
1308
|
+
|| managed.type !== intent.type
|
|
1309
|
+
|| managed.ttl !== intent.ttl
|
|
1310
|
+
) {
|
|
1179
1311
|
const result = await this.dcRouterRef.dnsManager!.updateRecord({
|
|
1180
1312
|
id: managed.id,
|
|
1181
1313
|
name: intent.name,
|
|
1314
|
+
type: intent.type,
|
|
1182
1315
|
value: intent.value,
|
|
1183
1316
|
ttl: intent.ttl,
|
|
1184
1317
|
});
|
|
@@ -1347,10 +1480,8 @@ export class MailDnsSync {
|
|
|
1347
1480
|
}
|
|
1348
1481
|
|
|
1349
1482
|
const canActivate = plan.egressIdentitySupported
|
|
1350
|
-
&& plan.revision.edgeIdentities.length
|
|
1351
|
-
&&
|
|
1352
|
-
? plan.revision.mxSlots.length === 2
|
|
1353
|
-
: plan.revision.mxSlots.length === 1)
|
|
1483
|
+
&& plan.revision.edgeIdentities.length === 2
|
|
1484
|
+
&& plan.revision.mxSlots.length === 2
|
|
1354
1485
|
&& plan.selectorCorrectSigningSupported
|
|
1355
1486
|
&& Boolean(this.getActiveDkim(plan.doc))
|
|
1356
1487
|
&& plan.intents.length > 0
|
|
@@ -1415,8 +1546,8 @@ export class MailDnsSync {
|
|
|
1415
1546
|
: { retryAt: new Date(Date.now() + retryDelay).toISOString() }),
|
|
1416
1547
|
};
|
|
1417
1548
|
const mailPublicationReady = plan.egressIdentitySupported
|
|
1418
|
-
&& plan.revision.edgeIdentities.length
|
|
1419
|
-
&& plan.revision.mxSlots.length
|
|
1549
|
+
&& plan.revision.edgeIdentities.length === 2
|
|
1550
|
+
&& plan.revision.mxSlots.length === 2;
|
|
1420
1551
|
plan.doc.dnsStatus = {
|
|
1421
1552
|
mx: mailPublicationReady
|
|
1422
1553
|
? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'MX')
|
|
@@ -1523,45 +1654,93 @@ export class MailDnsSync {
|
|
|
1523
1654
|
for (const doc of docs) {
|
|
1524
1655
|
if (doc.reconciliation?.lifecycleStatus === 'deleting') continue;
|
|
1525
1656
|
for (const intent of doc.reconciliation?.intents || []) keep.add(intent.key);
|
|
1526
|
-
|
|
1657
|
+
const hasCompleteDesiredMxSet = (doc.reconciliation?.intents || [])
|
|
1658
|
+
.filter((intent) => intent.type === 'MX').length === 2;
|
|
1659
|
+
if (doc.reconciliation?.lifecycleStatus !== 'active' && hasCompleteDesiredMxSet) {
|
|
1527
1660
|
for (const key of doc.reconciliation?.activeRevision?.recordKeys || []) keep.add(key);
|
|
1528
1661
|
}
|
|
1529
1662
|
}
|
|
1530
1663
|
const managed = (await DnsRecordDoc.findAll()).filter((record) => record.managedBy === MAIL_DNS_MANAGED_BY);
|
|
1531
|
-
|
|
1532
|
-
if (!record.managedOwnerId || !record.managedRecordKey)
|
|
1664
|
+
const staleRecords = managed.filter((record) => {
|
|
1665
|
+
if (!record.managedOwnerId || !record.managedRecordKey) return false;
|
|
1533
1666
|
if (
|
|
1534
1667
|
!selectedOwnerIds.has(record.managedOwnerId)
|
|
1535
1668
|
&& !(allowTopologyCleanup && record.managedOwnerId.startsWith('topology:'))
|
|
1536
|
-
)
|
|
1537
|
-
if (
|
|
1669
|
+
) return false;
|
|
1670
|
+
if (keep.has(record.managedRecordKey)) return false;
|
|
1538
1671
|
const zone = zones.find((candidate) => candidate.id === record.domainId);
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1672
|
+
return zone?.source !== 'provider' || provenProviderZoneIds.has(zone.id);
|
|
1673
|
+
});
|
|
1674
|
+
if (staleRecords.length === 0) return;
|
|
1675
|
+
const results = await this.dcRouterRef.dnsManager!.deleteRecords(
|
|
1676
|
+
staleRecords.map((record) => record.id),
|
|
1677
|
+
);
|
|
1678
|
+
for (let index = 0; index < staleRecords.length; index++) {
|
|
1679
|
+
const record = staleRecords[index];
|
|
1680
|
+
const result = results[index];
|
|
1681
|
+
if (result?.success) continue;
|
|
1682
|
+
const error = new Error(result?.message || 'delete failed');
|
|
1683
|
+
const domainOwnerId = record.managedOwnerId?.startsWith('domain:')
|
|
1684
|
+
? record.managedOwnerId.slice('domain:'.length)
|
|
1685
|
+
: undefined;
|
|
1686
|
+
const affectedOwners = domainOwnerId
|
|
1687
|
+
? docs.filter((doc) => doc.id === domainOwnerId)
|
|
1688
|
+
: docs.filter((doc) => selectedOwnerIds.has(`domain:${doc.id}`));
|
|
1689
|
+
if (affectedOwners.length > 0) {
|
|
1690
|
+
for (const owner of affectedOwners) {
|
|
1691
|
+
if (!owner.reconciliation) continue;
|
|
1547
1692
|
owner.reconciliation.errors.push({
|
|
1548
1693
|
code: 'DNS_CLEANUP_FAILED',
|
|
1549
|
-
message: `Failed to delete ${record.type} ${record.name}: ${
|
|
1694
|
+
message: `Failed to delete ${record.type} ${record.name}: ${error.message}`,
|
|
1550
1695
|
recordKey: record.managedRecordKey,
|
|
1551
1696
|
retryable: true,
|
|
1552
1697
|
});
|
|
1553
1698
|
owner.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
|
|
1699
|
+
if (owner.reconciliation.lifecycleStatus !== 'deleting') {
|
|
1700
|
+
owner.reconciliation.lifecycleStatus = 'pending';
|
|
1701
|
+
}
|
|
1554
1702
|
await owner.save();
|
|
1555
1703
|
}
|
|
1704
|
+
} else {
|
|
1705
|
+
logger.log(
|
|
1706
|
+
'error',
|
|
1707
|
+
`MailDnsSync failed to delete orphaned topology record ${record.type} ${record.name}: ${error.message}`,
|
|
1708
|
+
);
|
|
1556
1709
|
}
|
|
1557
1710
|
}
|
|
1558
1711
|
}
|
|
1559
1712
|
|
|
1713
|
+
private async pruneRetiredDkimMaterial(docs: EmailDomainDoc[]): Promise<void> {
|
|
1714
|
+
const now = Date.now();
|
|
1715
|
+
const records = await DnsRecordDoc.findAll();
|
|
1716
|
+
for (const plannedDoc of docs) {
|
|
1717
|
+
const doc = await EmailDomainDoc.findById(plannedDoc.id);
|
|
1718
|
+
if (!doc) continue;
|
|
1719
|
+
const retiring = doc.retiringDkim || [];
|
|
1720
|
+
const retained = retiring.filter((material) => {
|
|
1721
|
+
const retireAt = Date.parse(material.retireAfter || '');
|
|
1722
|
+
if (!Number.isFinite(retireAt) || retireAt > now) return true;
|
|
1723
|
+
const key = `mail-domain:${doc.id}:dkim:${material.selector}`;
|
|
1724
|
+
return records.some((record) => (
|
|
1725
|
+
record.managedBy === MAIL_DNS_MANAGED_BY
|
|
1726
|
+
&& record.managedOwnerId === `domain:${doc.id}`
|
|
1727
|
+
&& record.managedRecordKey === key
|
|
1728
|
+
));
|
|
1729
|
+
});
|
|
1730
|
+
if (retained.length === retiring.length) continue;
|
|
1731
|
+
doc.retiringDkim = retained;
|
|
1732
|
+
doc.updatedAt = new Date().toISOString();
|
|
1733
|
+
await doc.save();
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1560
1737
|
private async finalizeDeletingDomains(
|
|
1561
1738
|
deletingDocs: EmailDomainDoc[],
|
|
1562
1739
|
zones: DomainDoc[],
|
|
1563
1740
|
): Promise<void> {
|
|
1564
|
-
for (const
|
|
1741
|
+
for (const deletingDoc of deletingDocs) {
|
|
1742
|
+
const doc = await EmailDomainDoc.findById(deletingDoc.id);
|
|
1743
|
+
if (!doc) continue;
|
|
1565
1744
|
if (doc.reconciliation?.lifecycleStatus !== 'deleting') continue;
|
|
1566
1745
|
if (doc.reconciliation.providerZoneIds === undefined) continue;
|
|
1567
1746
|
if (doc.reconciliation.errors.some((error) => (
|
|
@@ -1587,6 +1766,20 @@ export class MailDnsSync {
|
|
|
1587
1766
|
await doc.save();
|
|
1588
1767
|
break;
|
|
1589
1768
|
}
|
|
1769
|
+
const stillPublishedIds = new Set(confirmation.listedProviderRecordIds || []);
|
|
1770
|
+
const lingeringProviderId = (doc.reconciliation.deletingProviderRecordIds || [])
|
|
1771
|
+
.find((providerRecordId) => stillPublishedIds.has(providerRecordId));
|
|
1772
|
+
if (lingeringProviderId) {
|
|
1773
|
+
providerProofComplete = false;
|
|
1774
|
+
doc.reconciliation.errors.push({
|
|
1775
|
+
code: 'DNS_CLEANUP_FAILED',
|
|
1776
|
+
message: `Provider zone ${zone.name} still publishes deleting record ${lingeringProviderId}`,
|
|
1777
|
+
retryable: true,
|
|
1778
|
+
});
|
|
1779
|
+
doc.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
|
|
1780
|
+
await doc.save();
|
|
1781
|
+
break;
|
|
1782
|
+
}
|
|
1590
1783
|
}
|
|
1591
1784
|
if (!providerProofComplete) continue;
|
|
1592
1785
|
const records = await DnsRecordDoc.findAll();
|
|
@@ -2,6 +2,7 @@ import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.
|
|
|
2
2
|
import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
|
3
3
|
import type { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
|
4
4
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
5
|
+
import type { IEmailDomainEdgeIdentity } from '../../ts_interfaces/data/email-domain.js';
|
|
5
6
|
|
|
6
7
|
export const MAIL_EGRESS_IDENTITY_MAX_AGE_MS = 45_000;
|
|
7
8
|
|
|
@@ -146,13 +147,22 @@ export class MailEdgeEligibility {
|
|
|
146
147
|
const hostname = edge.mailHostname?.toLowerCase().replace(/\.$/, '');
|
|
147
148
|
if (!hostname) continue;
|
|
148
149
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
const reported = (identitiesByEdge.get(edge.id) || []).filter((identity) => (
|
|
151
|
+
identity.heloHostname.toLowerCase().replace(/\.$/, '') === hostname
|
|
152
|
+
));
|
|
153
|
+
const expected = [
|
|
154
|
+
...(edge.publicIp ? [{ family: 4 as const, address: edge.publicIp }] : []),
|
|
155
|
+
...(edge.publicIpV6 ? [{ family: 6 as const, address: edge.publicIpV6 }] : []),
|
|
156
|
+
];
|
|
157
|
+
const identities = expected.flatMap((configured) => {
|
|
158
|
+
const matches = reported.filter((identity) => (
|
|
159
|
+
identity.family === configured.family && identity.address === configured.address
|
|
160
|
+
));
|
|
161
|
+
return matches.length === 1 ? matches : [];
|
|
154
162
|
});
|
|
155
|
-
|
|
163
|
+
// Every configured source family must have one fresh source-bound proof.
|
|
164
|
+
// A matching IPv4 observation never excuses an unproven configured IPv6.
|
|
165
|
+
if (expected.length > 0 && identities.length === expected.length) {
|
|
156
166
|
edges.push({ edge, hostname, identities });
|
|
157
167
|
}
|
|
158
168
|
}
|
|
@@ -179,27 +189,55 @@ export class MailEdgeEligibility {
|
|
|
179
189
|
|
|
180
190
|
public async resolveActiveForDomain(doc: EmailDomainDoc): Promise<IMailEdgeEligibilityResult> {
|
|
181
191
|
const live = await this.resolveForDomain(doc);
|
|
182
|
-
const
|
|
183
|
-
|
|
192
|
+
const reconciliation = doc.reconciliation;
|
|
193
|
+
const active = reconciliation?.activeRevision;
|
|
194
|
+
const slots = active?.mxSlots || [];
|
|
195
|
+
const completeSlots = slots.length === 2
|
|
196
|
+
&& slots[0]?.priority === 10
|
|
197
|
+
&& slots[1]?.priority === 20
|
|
198
|
+
&& slots[0].edgeId !== slots[1].edgeId
|
|
199
|
+
&& slots[0].hostname !== slots[1].hostname;
|
|
200
|
+
if (
|
|
201
|
+
!live.supported
|
|
202
|
+
|| reconciliation?.lifecycleStatus !== 'active'
|
|
203
|
+
|| reconciliation.errors.length > 0
|
|
204
|
+
|| !active
|
|
205
|
+
|| active.generation !== reconciliation.desiredGeneration
|
|
206
|
+
|| !completeSlots
|
|
207
|
+
) {
|
|
184
208
|
return {
|
|
185
209
|
supported: live.supported,
|
|
186
|
-
reason: live.reason || 'email domain has no validated
|
|
210
|
+
reason: live.reason || 'email domain has no current complete validated DNS revision',
|
|
187
211
|
edges: [],
|
|
188
212
|
};
|
|
189
213
|
}
|
|
190
|
-
const activeByEdge = new Map
|
|
191
|
-
|
|
214
|
+
const activeByEdge = new Map<string, IEmailDomainEdgeIdentity>(
|
|
215
|
+
active.edgeIdentities.map((identity) => [identity.edgeId, identity]),
|
|
216
|
+
);
|
|
217
|
+
const liveByEdge = new Map(live.edges.map((candidate) => [candidate.edge.id, candidate]));
|
|
218
|
+
const edges = slots.flatMap((slot) => {
|
|
219
|
+
const candidate = liveByEdge.get(slot.edgeId);
|
|
220
|
+
if (!candidate || candidate.hostname !== slot.hostname) return [];
|
|
192
221
|
const activeIdentity = activeByEdge.get(candidate.edge.id);
|
|
193
|
-
if (!activeIdentity || activeIdentity.hostname !== candidate.hostname) return
|
|
194
|
-
|
|
222
|
+
if (!activeIdentity || activeIdentity.hostname !== candidate.hostname) return [];
|
|
223
|
+
const expectedFamilies = [
|
|
224
|
+
...(activeIdentity.ipv4 ? [{ family: 4 as const, address: activeIdentity.ipv4 }] : []),
|
|
225
|
+
...(activeIdentity.ipv6 ? [{ family: 6 as const, address: activeIdentity.ipv6 }] : []),
|
|
226
|
+
];
|
|
227
|
+
const exact = candidate.identities.length === expectedFamilies.length
|
|
228
|
+
&& expectedFamilies.every((expected) => candidate.identities.some((identity) => (
|
|
229
|
+
identity.family === expected.family && identity.address === expected.address
|
|
230
|
+
)))
|
|
231
|
+
&& candidate.identities.every((identity) => (
|
|
195
232
|
(identity.family === 4 && identity.address === activeIdentity.ipv4)
|
|
196
233
|
|| (identity.family === 6 && identity.address === activeIdentity.ipv6)
|
|
197
234
|
));
|
|
235
|
+
return exact ? [candidate] : [];
|
|
198
236
|
});
|
|
199
237
|
return {
|
|
200
238
|
supported: live.supported,
|
|
201
|
-
...(edges.length
|
|
202
|
-
edges,
|
|
239
|
+
...(edges.length !== 2 ? { reason: 'both validated MX edges must be live with exact source-bound identities' } : {}),
|
|
240
|
+
edges: edges.length === 2 ? edges : [],
|
|
203
241
|
};
|
|
204
242
|
}
|
|
205
243
|
}
|