@serve.zone/dcrouter 16.4.1 → 16.5.0
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 +852 -852
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -1
- package/dist_ts/classes.dcrouter.js +20 -4
- package/dist_ts/db/documents/classes.dns-record.doc.d.ts +4 -0
- package/dist_ts/db/documents/classes.dns-record.doc.js +33 -2
- package/dist_ts/db/documents/classes.email-domain.doc.d.ts +5 -1
- package/dist_ts/db/documents/classes.email-domain.doc.js +26 -2
- package/dist_ts/dns/manager.dns.d.ts +6 -0
- package/dist_ts/dns/manager.dns.js +81 -32
- package/dist_ts/dns/providers/cloudflare.provider.d.ts +2 -0
- package/dist_ts/dns/providers/cloudflare.provider.js +45 -28
- package/dist_ts/email/classes.email-domain.manager.d.ts +43 -38
- package/dist_ts/email/classes.email-domain.manager.js +391 -324
- package/dist_ts/email/classes.mail-dns-sync.d.ts +58 -17
- package/dist_ts/email/classes.mail-dns-sync.js +857 -141
- package/dist_ts/email/classes.mail-edge-eligibility.d.ts +55 -0
- package/dist_ts/email/classes.mail-edge-eligibility.js +168 -0
- package/dist_ts/email/classes.mail-egress-coordinator.d.ts +3 -1
- package/dist_ts/email/classes.mail-egress-coordinator.js +43 -22
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +3 -0
- package/dist_ts/email/classes.workapp-mail-manager.js +58 -12
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/opsserver/handlers/email-domain.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/email-domain.handler.js +138 -22
- package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
- package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
- package/dist_ts/remoteingress/classes.tunnel-manager.js +40 -2
- package/dist_ts_interfaces/data/dns-record.d.ts +6 -0
- package/dist_ts_interfaces/data/email-domain.d.ts +81 -0
- package/dist_ts_interfaces/requests/email-domains.d.ts +25 -17
- package/dist_ts_migrations/index.js +152 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/email-domains.d.ts +16 -0
- package/dist_ts_web/appstate/email-domains.js +140 -19
- package/dist_ts_web/elements/email/ops-view-email-domains.d.ts +1 -0
- package/dist_ts_web/elements/email/ops-view-email-domains.js +36 -13
- package/package.json +3 -3
- package/readme.md +13 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +22 -3
- package/ts/db/documents/classes.dns-record.doc.ts +20 -0
- package/ts/db/documents/classes.email-domain.doc.ts +14 -0
- package/ts/dns/manager.dns.ts +89 -29
- package/ts/dns/providers/cloudflare.provider.ts +46 -27
- package/ts/email/classes.email-domain.manager.ts +416 -368
- package/ts/email/classes.mail-dns-sync.ts +913 -146
- package/ts/email/classes.mail-edge-eligibility.ts +205 -0
- package/ts/email/classes.mail-egress-coordinator.ts +46 -23
- package/ts/email/classes.workapp-mail-manager.ts +69 -13
- package/ts/email/index.ts +1 -0
- package/ts/opsserver/handlers/email-domain.handler.ts +154 -21
- package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
- package/ts/remoteingress/classes.tunnel-manager.ts +53 -7
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/email-domains.ts +187 -18
- package/ts_web/elements/email/ops-view-email-domains.ts +44 -12
|
@@ -1,218 +1,985 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
1
2
|
import { logger } from '../logger.js';
|
|
2
3
|
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
|
3
4
|
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
|
4
5
|
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
|
5
6
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
6
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
IEmailDnsRecordIntent,
|
|
9
|
+
IEmailDomainDnsRevision,
|
|
10
|
+
IEmailDomainEdgeIdentity,
|
|
11
|
+
IEmailDomainMxSlot,
|
|
12
|
+
IEmailDomainOperationError,
|
|
13
|
+
IEmailDkimMaterial,
|
|
14
|
+
} from '../../ts_interfaces/data/email-domain.js';
|
|
15
|
+
import type { TDnsRecordType } from '../../ts_interfaces/data/dns-record.js';
|
|
16
|
+
import {
|
|
17
|
+
MailEdgeEligibility,
|
|
18
|
+
ReleasedRemoteIngressEgressIdentitySource,
|
|
19
|
+
type IEligibleMailEdge,
|
|
20
|
+
} from './classes.mail-edge-eligibility.js';
|
|
7
21
|
|
|
8
|
-
export const MAIL_DNS_SYNC_CREATED_BY = 'mail-
|
|
22
|
+
export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
|
|
23
|
+
export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
|
|
24
|
+
export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
9
25
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
|
|
27
|
+
export interface IMailDnsVerificationResult {
|
|
28
|
+
valid: boolean;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IMailDnsVerifier {
|
|
33
|
+
verifyRecord(intent: IEmailDnsRecordIntent): Promise<IMailDnsVerificationResult>;
|
|
34
|
+
verifyMxSet?(intents: IEmailDnsRecordIntent[]): Promise<IMailDnsVerificationResult>;
|
|
35
|
+
verifyForwardConfirmedIdentity(
|
|
36
|
+
identity: IEmailDomainEdgeIdentity,
|
|
37
|
+
): Promise<IMailDnsVerificationResult>;
|
|
38
|
+
destroy?(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeHostname(value: string): string {
|
|
42
|
+
return value.trim().toLowerCase().replace(/\.$/, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeMx(value: string): string {
|
|
46
|
+
const [priority, exchange = ''] = value.trim().split(/\s+/);
|
|
47
|
+
return `${Number(priority)} ${normalizeHostname(exchange)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function expandIpv6(address: string): string[] | undefined {
|
|
51
|
+
let normalized = address.toLowerCase();
|
|
52
|
+
if (normalized.includes('.')) {
|
|
53
|
+
const lastColon = normalized.lastIndexOf(':');
|
|
54
|
+
const ipv4 = normalized.slice(lastColon + 1).split('.').map(Number);
|
|
55
|
+
if (ipv4.length !== 4 || ipv4.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
normalized = `${normalized.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
|
|
59
|
+
}
|
|
60
|
+
const halves = normalized.split('::');
|
|
61
|
+
if (halves.length > 2) return undefined;
|
|
62
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
63
|
+
const right = halves[1] ? halves[1].split(':') : [];
|
|
64
|
+
const missing = 8 - left.length - right.length;
|
|
65
|
+
if ((halves.length === 1 && missing !== 0) || missing < 0) return undefined;
|
|
66
|
+
const groups = [...left, ...Array.from({ length: missing }, () => '0'), ...right];
|
|
67
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return undefined;
|
|
68
|
+
return groups.map((group) => group.padStart(4, '0'));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function toReverseDnsName(address: string): string {
|
|
72
|
+
const family = plugins.smartnetwork.getIpVersion(address);
|
|
73
|
+
if (family === 4) {
|
|
74
|
+
return `${address.split('.').reverse().join('.')}.in-addr.arpa`;
|
|
75
|
+
}
|
|
76
|
+
if (family === 6) {
|
|
77
|
+
const groups = expandIpv6(address);
|
|
78
|
+
if (!groups) throw new Error(`Invalid IPv6 address: ${address}`);
|
|
79
|
+
return `${groups.join('').split('').reverse().join('.')}.ip6.arpa`;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Invalid IP address: ${address}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class SmartDnsMailVerifier implements IMailDnsVerifier {
|
|
85
|
+
private client?: plugins.smartdns.dnsClientMod.Smartdns;
|
|
86
|
+
|
|
87
|
+
private getClient(): plugins.smartdns.dnsClientMod.Smartdns {
|
|
88
|
+
this.client ||= new plugins.smartdns.dnsClientMod.Smartdns({});
|
|
89
|
+
return this.client;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public async verifyRecord(intent: IEmailDnsRecordIntent): Promise<IMailDnsVerificationResult> {
|
|
93
|
+
try {
|
|
94
|
+
const records = await this.getClient().getRecords(intent.name, intent.type);
|
|
95
|
+
const values = (records || []).map((record) => String(record.value).trim());
|
|
96
|
+
if (intent.type === 'MX') {
|
|
97
|
+
return values.some((value) => normalizeMx(value) === normalizeMx(intent.value))
|
|
98
|
+
? { valid: true }
|
|
99
|
+
: { valid: false, reason: `MX ${intent.value} has not propagated` };
|
|
100
|
+
}
|
|
101
|
+
if (intent.type === 'TXT') {
|
|
102
|
+
const exact = values.filter((value) => value === intent.value.trim());
|
|
103
|
+
const relevant = intent.value.startsWith('v=spf1')
|
|
104
|
+
? values.filter((value) => value.toLowerCase().startsWith('v=spf1'))
|
|
105
|
+
: intent.value.startsWith('v=DMARC1')
|
|
106
|
+
? values.filter((value) => value.toLowerCase().startsWith('v=dmarc1'))
|
|
107
|
+
: values;
|
|
108
|
+
return exact.length === 1 && relevant.length === 1
|
|
109
|
+
? { valid: true }
|
|
110
|
+
: { valid: false, reason: `TXT ${intent.name} is missing, conflicting, or duplicated` };
|
|
111
|
+
}
|
|
112
|
+
const exact = values.filter((value) => normalizeHostname(value) === normalizeHostname(intent.value));
|
|
113
|
+
return exact.length === 1 && values.length === 1
|
|
114
|
+
? { valid: true }
|
|
115
|
+
: { valid: false, reason: `${intent.type} ${intent.name} is missing, conflicting, or duplicated` };
|
|
116
|
+
} catch (error: unknown) {
|
|
117
|
+
return { valid: false, reason: `DNS lookup failed: ${(error as Error).message}` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public async verifyMxSet(intents: IEmailDnsRecordIntent[]): Promise<IMailDnsVerificationResult> {
|
|
122
|
+
if (intents.length === 0) return { valid: false, reason: 'No desired MX records' };
|
|
123
|
+
try {
|
|
124
|
+
const records = await this.getClient().getRecords(intents[0].name, 'MX');
|
|
125
|
+
const actual = (records || []).map((record) => normalizeMx(String(record.value))).sort();
|
|
126
|
+
const desired = intents.map((intent) => normalizeMx(intent.value)).sort();
|
|
127
|
+
return actual.length === desired.length && actual.every((value, index) => value === desired[index])
|
|
128
|
+
? { valid: true }
|
|
129
|
+
: {
|
|
130
|
+
valid: false,
|
|
131
|
+
reason: `MX set for ${intents[0].name} does not exactly match priorities and exchanges`,
|
|
132
|
+
};
|
|
133
|
+
} catch (error: unknown) {
|
|
134
|
+
return { valid: false, reason: `MX lookup failed: ${(error as Error).message}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public async verifyForwardConfirmedIdentity(
|
|
139
|
+
identity: IEmailDomainEdgeIdentity,
|
|
140
|
+
): Promise<IMailDnsVerificationResult> {
|
|
141
|
+
const checks: Array<{ family: 'A' | 'AAAA'; address: string }> = [];
|
|
142
|
+
if (identity.ipv4) checks.push({ family: 'A', address: identity.ipv4 });
|
|
143
|
+
if (identity.ipv6) checks.push({ family: 'AAAA', address: identity.ipv6 });
|
|
144
|
+
try {
|
|
145
|
+
for (const check of checks) {
|
|
146
|
+
const ptrRecords = await this.getClient().getRecords(toReverseDnsName(check.address), 'PTR');
|
|
147
|
+
const ptrNames = (ptrRecords || []).map((record) => normalizeHostname(String(record.value)));
|
|
148
|
+
if (ptrNames.length !== 1 || ptrNames[0] !== normalizeHostname(identity.hostname)) {
|
|
149
|
+
return {
|
|
150
|
+
valid: false,
|
|
151
|
+
reason: `PTR for ${check.address} must be exactly ${identity.hostname}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const forwardRecords = await this.getClient().getRecords(identity.hostname, check.family);
|
|
155
|
+
const forwardIps = (forwardRecords || []).map((record) => String(record.value));
|
|
156
|
+
if (!forwardIps.includes(check.address)) {
|
|
157
|
+
return {
|
|
158
|
+
valid: false,
|
|
159
|
+
reason: `${check.family} ${identity.hostname} does not resolve back to ${check.address}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return checks.length > 0
|
|
164
|
+
? { valid: true }
|
|
165
|
+
: { valid: false, reason: `No source-bound address is available for ${identity.edgeId}` };
|
|
166
|
+
} catch (error: unknown) {
|
|
167
|
+
return { valid: false, reason: `FCrDNS lookup failed: ${(error as Error).message}` };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public destroy(): void {
|
|
172
|
+
this.client?.destroy();
|
|
173
|
+
this.client = undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface IPlannedDomain {
|
|
178
|
+
doc: EmailDomainDoc;
|
|
179
|
+
revision: IEmailDomainDnsRevision;
|
|
180
|
+
intents: IEmailDnsRecordIntent[];
|
|
181
|
+
preliminaryErrors: IEmailDomainOperationError[];
|
|
182
|
+
egressIdentitySupported: boolean;
|
|
183
|
+
selectorCorrectSigningSupported: boolean;
|
|
14
184
|
}
|
|
15
185
|
|
|
16
186
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* (a) shared mail hostname → publicIp/publicIpV6 of every enabled mail edge,
|
|
20
|
-
* (b) each edge's mailHostname → that edge's IPs (FCrDNS for outbound EHLO),
|
|
21
|
-
* (c) mx.<domain> → the pinned edges' IPs for every pinned email domain.
|
|
22
|
-
* Records are DB-backed DnsRecordDoc rows owned via createdBy 'mail-edge-sync';
|
|
23
|
-
* provider-hosted zones (e.g. Cloudflare) are written through DnsManager's
|
|
24
|
-
* provider path, dcrouter-authoritative zones serve immediately.
|
|
187
|
+
* Sole owner of managed mail DNS. API, topology, timer, and startup events only
|
|
188
|
+
* mark this globally serialized reconciler dirty.
|
|
25
189
|
*/
|
|
26
190
|
export class MailDnsSync {
|
|
27
|
-
private
|
|
28
|
-
private
|
|
191
|
+
private started = false;
|
|
192
|
+
private stopped = false;
|
|
193
|
+
private dirty = false;
|
|
194
|
+
private runPromise?: Promise<void>;
|
|
195
|
+
private retryTimer?: ReturnType<typeof setInterval>;
|
|
196
|
+
private edgeEligibility: MailEdgeEligibility;
|
|
197
|
+
|
|
198
|
+
constructor(
|
|
199
|
+
private dcRouterRef: DcRouter,
|
|
200
|
+
edgeEligibility?: MailEdgeEligibility,
|
|
201
|
+
private verifier: IMailDnsVerifier = new SmartDnsMailVerifier(),
|
|
202
|
+
) {
|
|
203
|
+
this.edgeEligibility = edgeEligibility
|
|
204
|
+
|| dcRouterRef.mailEdgeEligibility
|
|
205
|
+
|| new MailEdgeEligibility(dcRouterRef, new ReleasedRemoteIngressEgressIdentitySource(dcRouterRef));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
public async start(): Promise<void> {
|
|
209
|
+
if (this.started) {
|
|
210
|
+
await this.runPromise;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.started = true;
|
|
214
|
+
this.stopped = false;
|
|
215
|
+
this.retryTimer = setInterval(() => this.requestSync('periodic retry'), MAIL_DNS_RETRY_INTERVAL_MS);
|
|
216
|
+
this.dirty = true;
|
|
217
|
+
try {
|
|
218
|
+
await this.kick('startup');
|
|
219
|
+
} catch (error: unknown) {
|
|
220
|
+
if (this.retryTimer) clearInterval(this.retryTimer);
|
|
221
|
+
this.retryTimer = undefined;
|
|
222
|
+
this.started = false;
|
|
223
|
+
this.dirty = false;
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
29
227
|
|
|
30
|
-
|
|
228
|
+
public async stop(): Promise<void> {
|
|
229
|
+
this.stopped = true;
|
|
230
|
+
this.started = false;
|
|
231
|
+
this.dirty = false;
|
|
232
|
+
if (this.retryTimer) {
|
|
233
|
+
clearInterval(this.retryTimer);
|
|
234
|
+
this.retryTimer = undefined;
|
|
235
|
+
}
|
|
236
|
+
await this.runPromise?.catch(() => undefined);
|
|
237
|
+
this.verifier.destroy?.();
|
|
238
|
+
}
|
|
31
239
|
|
|
32
|
-
/** Fire-and-forget trigger for CRUD hooks; failures are logged, not thrown. */
|
|
33
240
|
public requestSync(reason: string): void {
|
|
34
|
-
this.
|
|
35
|
-
|
|
241
|
+
if (this.stopped) return;
|
|
242
|
+
this.dirty = true;
|
|
243
|
+
if (!this.started) return;
|
|
244
|
+
this.kick(reason).catch((error: unknown) => {
|
|
245
|
+
logger.log('error', `MailDnsSync failed (${reason}): ${(error as Error).message}`);
|
|
36
246
|
});
|
|
37
247
|
}
|
|
38
248
|
|
|
39
|
-
/** Coalescing entry point
|
|
249
|
+
/** Coalescing entry point used by API actions that need the durable outcome. */
|
|
40
250
|
public async sync(reason: string): Promise<void> {
|
|
41
|
-
if (this.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
251
|
+
if (this.stopped) throw new Error('MailDnsSync is stopped');
|
|
252
|
+
this.dirty = true;
|
|
253
|
+
await this.kick(reason);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async kick(reason: string): Promise<void> {
|
|
257
|
+
if (this.runPromise) return await this.runPromise;
|
|
258
|
+
// Defer execution one microtask so runPromise is assigned before the first
|
|
259
|
+
// awaited provider/eligibility hook can synchronously enqueue another event.
|
|
260
|
+
this.runPromise = Promise.resolve().then(() => this.runLoop(reason, false)).finally(() => {
|
|
261
|
+
this.runPromise = undefined;
|
|
262
|
+
if (this.dirty && this.started && !this.stopped) {
|
|
263
|
+
this.requestSync(`${reason} (follow-up)`);
|
|
50
264
|
}
|
|
51
265
|
});
|
|
52
|
-
return this.
|
|
266
|
+
return await this.runPromise;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async runLoop(reason: string, singlePass: boolean): Promise<void> {
|
|
270
|
+
do {
|
|
271
|
+
this.dirty = false;
|
|
272
|
+
await this.reconcile(reason);
|
|
273
|
+
if (singlePass) break;
|
|
274
|
+
// An event during the run leaves dirty=true and forces exactly one
|
|
275
|
+
// immediate follow-up; repeated events coalesce into that pass.
|
|
276
|
+
} while (this.dirty && !this.stopped);
|
|
53
277
|
}
|
|
54
278
|
|
|
55
279
|
private async reconcile(reason: string): Promise<void> {
|
|
56
|
-
if (this.dcRouterRef.options.dbConfig?.enabled === false)
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
280
|
+
if (this.dcRouterRef.options.dbConfig?.enabled === false) return;
|
|
59
281
|
const dnsManager = this.dcRouterRef.dnsManager;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// Without both managers the edge registry / record store is not
|
|
63
|
-
// authoritative — skip entirely rather than delete managed records.
|
|
282
|
+
if (!dnsManager) {
|
|
283
|
+
logger.log('warn', `MailDnsSync (${reason}): DnsManager unavailable; retry intent retained`);
|
|
64
284
|
return;
|
|
65
285
|
}
|
|
66
286
|
|
|
67
|
-
|
|
68
|
-
|
|
287
|
+
// Refresh the broad operational status independently from per-domain pins.
|
|
288
|
+
await this.edgeEligibility.resolveForDomain();
|
|
69
289
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
290
|
+
const zones = await DomainDoc.findAll();
|
|
291
|
+
const docs = await EmailDomainDoc.findAll();
|
|
292
|
+
const plans: IPlannedDomain[] = [];
|
|
293
|
+
for (const loadedDoc of docs) {
|
|
294
|
+
const doc = await this.dcRouterRef.emailDomainManager?.stageDkimRotationIfDue?.(loadedDoc.id)
|
|
295
|
+
|| loadedDoc;
|
|
296
|
+
if (doc.reconciliation?.lifecycleStatus === 'deleting') {
|
|
297
|
+
await this.persistDeletingAttempt(doc);
|
|
78
298
|
continue;
|
|
79
299
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
300
|
+
const plan = await this.buildPlan(doc, zones);
|
|
301
|
+
await this.persistDesiredPlan(plan);
|
|
302
|
+
plans.push(plan);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const uniqueIntents = new Map<string, IEmailDnsRecordIntent>();
|
|
306
|
+
for (const plan of plans) {
|
|
307
|
+
for (const intent of plan.intents) {
|
|
308
|
+
uniqueIntents.set(intent.key, intent);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const zoneErrors = await this.reconcileZones([...uniqueIntents.values()], zones);
|
|
313
|
+
for (const plan of plans) {
|
|
314
|
+
const errors = [...plan.preliminaryErrors];
|
|
315
|
+
for (const intent of plan.intents) {
|
|
316
|
+
const outcome = uniqueIntents.get(intent.key);
|
|
317
|
+
if (outcome) Object.assign(intent, outcome);
|
|
318
|
+
if (intent.error) errors.push(intent.error);
|
|
319
|
+
}
|
|
320
|
+
await this.validateAndActivate(plan, errors, zoneErrors);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await this.cleanupStaleManagedRecords(zones);
|
|
324
|
+
await this.finalizeDeletingDomains();
|
|
325
|
+
await this.dcRouterRef.emailDomainManager?.syncManagedDomainsToRuntime();
|
|
326
|
+
await this.dcRouterRef.workAppMailManager?.applyStoredIdentitiesToRuntime();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async buildPlan(doc: EmailDomainDoc, zones: DomainDoc[]): Promise<IPlannedDomain> {
|
|
330
|
+
const now = new Date().toISOString();
|
|
331
|
+
const eligibility = await this.edgeEligibility.resolveForDomain(doc);
|
|
332
|
+
const preliminaryErrors: IEmailDomainOperationError[] = [
|
|
333
|
+
...(doc.reconciliation?.errors || []).filter((error) => error.code === 'DKIM_ROTATION_STAGE_FAILED'),
|
|
334
|
+
];
|
|
335
|
+
if (!eligibility.supported) {
|
|
336
|
+
preliminaryErrors.push({
|
|
337
|
+
code: 'EGRESS_IDENTITY_PROOF_UNAVAILABLE',
|
|
338
|
+
message: eligibility.reason || 'RemoteIngress egress identity proof is unavailable',
|
|
339
|
+
retryable: true,
|
|
340
|
+
});
|
|
341
|
+
} else if (eligibility.edges.length === 0) {
|
|
342
|
+
preliminaryErrors.push({
|
|
343
|
+
code: 'NO_ELIGIBLE_MAIL_EDGE',
|
|
344
|
+
message: eligibility.reason || 'No eligible mail edge is connected',
|
|
345
|
+
retryable: true,
|
|
83
346
|
});
|
|
84
347
|
}
|
|
85
|
-
|
|
86
|
-
|
|
348
|
+
|
|
349
|
+
const activeDkim = this.getActiveDkim(doc);
|
|
350
|
+
const upstreamSelectorCapability = this.dcRouterRef.emailDomainManager?.supportsSelectorCorrectSigning?.() === true;
|
|
351
|
+
const selectorCorrectSigningSupported = (
|
|
352
|
+
(!activeDkim || activeDkim.selector === 'default') && !doc.pendingDkim
|
|
353
|
+
) || upstreamSelectorCapability;
|
|
354
|
+
if (!activeDkim) {
|
|
355
|
+
preliminaryErrors.push({
|
|
356
|
+
code: 'DKIM_KEY_UNAVAILABLE',
|
|
357
|
+
message: `No usable active DKIM key exists for ${doc.domain}`,
|
|
358
|
+
retryable: false,
|
|
359
|
+
});
|
|
360
|
+
} else if (!selectorCorrectSigningSupported) {
|
|
361
|
+
preliminaryErrors.push({
|
|
362
|
+
code: 'SMARTMTA_SELECTOR_SIGNING_UNSUPPORTED',
|
|
363
|
+
message: 'The consumed SmartMTA release cannot safely sign with a non-default selector',
|
|
364
|
+
retryable: false,
|
|
365
|
+
});
|
|
87
366
|
}
|
|
88
367
|
|
|
89
|
-
const
|
|
90
|
-
|
|
368
|
+
const edgeIdentities = eligibility.edges.map((candidate) => this.toDomainEdgeIdentity(candidate));
|
|
369
|
+
const mxSlots = this.allocateMxSlots(doc, eligibility.edges);
|
|
370
|
+
if (eligibility.edges.length >= 2 && mxSlots.length < 2) {
|
|
371
|
+
preliminaryErrors.push({
|
|
372
|
+
code: 'INSUFFICIENT_DISTINCT_MX_IDENTITIES',
|
|
373
|
+
message: 'At least two eligible mail edges exist but they do not provide two distinct MX hostnames',
|
|
374
|
+
retryable: false,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
const revisionId = plugins.smartunique.shortId();
|
|
378
|
+
const intents: IEmailDnsRecordIntent[] = [];
|
|
91
379
|
|
|
92
|
-
|
|
93
|
-
|
|
380
|
+
for (const candidate of eligibility.edges) {
|
|
381
|
+
for (const identity of candidate.identities) {
|
|
382
|
+
const type = identity.family === 4 ? 'A' : 'AAAA';
|
|
383
|
+
intents.push(this.intentForName(zones, {
|
|
384
|
+
key: `mail-topology:${candidate.edge.id}:${type}:${candidate.hostname}:${identity.address}`,
|
|
385
|
+
ownerType: 'topology',
|
|
386
|
+
ownerId: `topology:${candidate.edge.id}`,
|
|
387
|
+
name: candidate.hostname,
|
|
388
|
+
type,
|
|
389
|
+
value: identity.address,
|
|
390
|
+
ttl: 300,
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
94
394
|
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
395
|
+
if (eligibility.edges.length > 0) {
|
|
396
|
+
for (const slot of mxSlots) {
|
|
397
|
+
intents.push(this.intentForName(zones, {
|
|
398
|
+
key: `mail-domain:${doc.id}:mx:${slot.priority}:${slot.edgeId}:${slot.hostname}`,
|
|
399
|
+
ownerType: 'domain',
|
|
400
|
+
ownerId: `domain:${doc.id}`,
|
|
401
|
+
name: doc.domain,
|
|
402
|
+
type: 'MX',
|
|
403
|
+
value: `${slot.priority} ${slot.hostname}`,
|
|
404
|
+
ttl: 300,
|
|
405
|
+
}));
|
|
100
406
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
407
|
+
const spfIdentities = [
|
|
408
|
+
...edgeIdentities,
|
|
409
|
+
...(doc.reconciliation?.activeRevision?.edgeIdentities || []),
|
|
410
|
+
];
|
|
411
|
+
intents.push(this.intentForName(zones, {
|
|
412
|
+
key: `mail-domain:${doc.id}:spf`,
|
|
413
|
+
ownerType: 'domain',
|
|
414
|
+
ownerId: `domain:${doc.id}`,
|
|
415
|
+
name: doc.domain,
|
|
416
|
+
type: 'TXT',
|
|
417
|
+
value: this.buildSpfValue(spfIdentities),
|
|
418
|
+
ttl: 300,
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (activeDkim) intents.push(this.buildDkimIntent(doc, activeDkim, zones));
|
|
423
|
+
if (doc.pendingDkim) intents.push(this.buildDkimIntent(doc, doc.pendingDkim, zones));
|
|
424
|
+
for (const retiring of doc.retiringDkim || []) {
|
|
425
|
+
if (!retiring.retireAfter || Date.parse(retiring.retireAfter) > Date.now()) {
|
|
426
|
+
intents.push(this.buildDkimIntent(doc, retiring, zones));
|
|
106
427
|
}
|
|
107
428
|
}
|
|
429
|
+
intents.push(this.intentForName(zones, {
|
|
430
|
+
key: `mail-domain:${doc.id}:dmarc`,
|
|
431
|
+
ownerType: 'domain',
|
|
432
|
+
ownerId: `domain:${doc.id}`,
|
|
433
|
+
name: `_dmarc.${doc.domain}`,
|
|
434
|
+
type: 'TXT',
|
|
435
|
+
value: `v=DMARC1; p=none; rua=mailto:dmarc@${doc.domain}`,
|
|
436
|
+
ttl: 3600,
|
|
437
|
+
}));
|
|
108
438
|
|
|
109
|
-
for (const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
119
|
-
if (result.success) {
|
|
120
|
-
created++;
|
|
121
|
-
} else {
|
|
122
|
-
logger.log('warn', `MailDnsSync: failed to create ${record.type} ${record.name} → ${record.value}: ${result.message}`);
|
|
439
|
+
for (const intent of intents) {
|
|
440
|
+
if (!intent.domainId) {
|
|
441
|
+
intent.status = 'failed';
|
|
442
|
+
intent.error = {
|
|
443
|
+
code: 'DNS_ZONE_UNMANAGED',
|
|
444
|
+
message: `No managed DNS zone covers ${intent.name}`,
|
|
445
|
+
recordKey: intent.key,
|
|
446
|
+
retryable: false,
|
|
447
|
+
};
|
|
123
448
|
}
|
|
124
449
|
}
|
|
450
|
+
const revision: IEmailDomainDnsRevision = {
|
|
451
|
+
id: revisionId,
|
|
452
|
+
createdAt: now,
|
|
453
|
+
selector: doc.pendingDkim?.selector || activeDkim?.selector || doc.dkim.selector || 'default',
|
|
454
|
+
edgeIdentities,
|
|
455
|
+
mxSlots,
|
|
456
|
+
recordKeys: intents.map((intent) => intent.key),
|
|
457
|
+
};
|
|
458
|
+
return {
|
|
459
|
+
doc,
|
|
460
|
+
revision,
|
|
461
|
+
intents,
|
|
462
|
+
preliminaryErrors,
|
|
463
|
+
egressIdentitySupported: eligibility.supported,
|
|
464
|
+
selectorCorrectSigningSupported,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private getActiveDkim(doc: EmailDomainDoc): IEmailDkimMaterial | undefined {
|
|
469
|
+
if (doc.activeDkim?.publicKey) return doc.activeDkim;
|
|
470
|
+
const publicKey = doc.dkim?.publicKey?.trim();
|
|
471
|
+
if (!publicKey) return undefined;
|
|
472
|
+
return {
|
|
473
|
+
selector: doc.dkim.selector || 'default',
|
|
474
|
+
keySize: doc.dkim.keySize || 2048,
|
|
475
|
+
publicKey,
|
|
476
|
+
createdAt: doc.createdAt,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
125
479
|
|
|
126
|
-
|
|
127
|
-
|
|
480
|
+
private buildDkimIntent(
|
|
481
|
+
doc: EmailDomainDoc,
|
|
482
|
+
material: IEmailDkimMaterial,
|
|
483
|
+
zones: DomainDoc[],
|
|
484
|
+
): IEmailDnsRecordIntent {
|
|
485
|
+
return this.intentForName(zones, {
|
|
486
|
+
key: `mail-domain:${doc.id}:dkim:${material.selector}`,
|
|
487
|
+
ownerType: 'domain',
|
|
488
|
+
ownerId: `domain:${doc.id}`,
|
|
489
|
+
name: `${material.selector}._domainkey.${doc.domain}`,
|
|
490
|
+
type: 'TXT',
|
|
491
|
+
value: `v=DKIM1; h=sha256; k=rsa; p=${material.publicKey}`,
|
|
492
|
+
ttl: 3600,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private intentForName(
|
|
497
|
+
zones: DomainDoc[],
|
|
498
|
+
input: Omit<IEmailDnsRecordIntent, 'domainId' | 'status'>,
|
|
499
|
+
): IEmailDnsRecordIntent {
|
|
500
|
+
return {
|
|
501
|
+
...input,
|
|
502
|
+
domainId: this.findZoneForName(input.name, zones)?.id || '',
|
|
503
|
+
status: 'pending',
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private toDomainEdgeIdentity(candidate: IEligibleMailEdge): IEmailDomainEdgeIdentity {
|
|
508
|
+
const ipv4 = candidate.identities.find((identity) => identity.family === 4);
|
|
509
|
+
const ipv6 = candidate.identities.find((identity) => identity.family === 6);
|
|
510
|
+
const observedAt = Math.min(...candidate.identities.map((identity) => identity.observedAt));
|
|
511
|
+
const hubReceivedAt = Math.min(...candidate.identities.map((identity) => identity.hubReceivedAt));
|
|
512
|
+
return {
|
|
513
|
+
edgeId: candidate.edge.id,
|
|
514
|
+
hostname: candidate.hostname,
|
|
515
|
+
...(ipv4 ? { ipv4: ipv4.address } : {}),
|
|
516
|
+
...(ipv6 ? { ipv6: ipv6.address } : {}),
|
|
517
|
+
observedAt: new Date(observedAt).toISOString(),
|
|
518
|
+
hubReceivedAt: new Date(hubReceivedAt).toISOString(),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private allocateMxSlots(doc: EmailDomainDoc, candidates: IEligibleMailEdge[]): IEmailDomainMxSlot[] {
|
|
523
|
+
if (candidates.length === 1) {
|
|
524
|
+
return [{
|
|
525
|
+
priority: 10,
|
|
526
|
+
edgeId: candidates[0].edge.id,
|
|
527
|
+
hostname: candidates[0].hostname,
|
|
528
|
+
}];
|
|
529
|
+
}
|
|
530
|
+
const byId = new Map(candidates.map((candidate) => [candidate.edge.id, candidate]));
|
|
531
|
+
const result: IEmailDomainMxSlot[] = [];
|
|
532
|
+
const usedEdges = new Set<string>();
|
|
533
|
+
const usedHostnames = new Set<string>();
|
|
534
|
+
const previous = [
|
|
535
|
+
...(doc.reconciliation?.activeRevision?.mxSlots || []),
|
|
536
|
+
...(doc.reconciliation?.pendingRevision?.mxSlots || []),
|
|
537
|
+
];
|
|
538
|
+
for (const priority of [10, 20] as const) {
|
|
539
|
+
const old = previous.find((slot) => slot.priority === priority);
|
|
540
|
+
const candidate = old ? byId.get(old.edgeId) : undefined;
|
|
541
|
+
if (!candidate || candidate.hostname !== old?.hostname) continue;
|
|
542
|
+
if (usedEdges.has(candidate.edge.id) || usedHostnames.has(candidate.hostname)) continue;
|
|
543
|
+
result.push({ priority, edgeId: candidate.edge.id, hostname: candidate.hostname });
|
|
544
|
+
usedEdges.add(candidate.edge.id);
|
|
545
|
+
usedHostnames.add(candidate.hostname);
|
|
128
546
|
}
|
|
547
|
+
for (const priority of [10, 20] as const) {
|
|
548
|
+
if (result.some((slot) => slot.priority === priority)) continue;
|
|
549
|
+
const candidate = candidates.find((entry) => (
|
|
550
|
+
!usedEdges.has(entry.edge.id) && !usedHostnames.has(entry.hostname)
|
|
551
|
+
));
|
|
552
|
+
if (!candidate) continue;
|
|
553
|
+
result.push({ priority, edgeId: candidate.edge.id, hostname: candidate.hostname });
|
|
554
|
+
usedEdges.add(candidate.edge.id);
|
|
555
|
+
usedHostnames.add(candidate.hostname);
|
|
556
|
+
}
|
|
557
|
+
return result.sort((left, right) => left.priority - right.priority);
|
|
129
558
|
}
|
|
130
559
|
|
|
131
|
-
private
|
|
132
|
-
const
|
|
133
|
-
const
|
|
560
|
+
private buildSpfValue(identities: IEmailDomainEdgeIdentity[]): string {
|
|
561
|
+
const terms = new Set<string>();
|
|
562
|
+
for (const identity of identities) {
|
|
563
|
+
if (identity.ipv4) terms.add(`ip4:${identity.ipv4}`);
|
|
564
|
+
if (identity.ipv6) terms.add(`ip6:${identity.ipv6}`);
|
|
565
|
+
}
|
|
566
|
+
return `v=spf1 ${[...terms].sort().join(' ')} -all`.replace(/\s+/g, ' ').trim();
|
|
567
|
+
}
|
|
134
568
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
569
|
+
private async persistDesiredPlan(plan: IPlannedDomain): Promise<void> {
|
|
570
|
+
const previous = plan.doc.reconciliation;
|
|
571
|
+
const previousSignature = JSON.stringify((previous?.intents || []).map((intent) => [intent.key, intent.value]));
|
|
572
|
+
const nextSignature = JSON.stringify(plan.intents.map((intent) => [intent.key, intent.value]));
|
|
573
|
+
const generation = (previous?.desiredGeneration || 0) + (previousSignature === nextSignature ? 0 : 1);
|
|
574
|
+
plan.doc.reconciliation = {
|
|
575
|
+
lifecycleStatus: previous?.lifecycleStatus === 'active' ? 'active' : 'pending',
|
|
576
|
+
desiredGeneration: Math.max(generation, 1),
|
|
577
|
+
activeRevision: previous?.activeRevision,
|
|
578
|
+
pendingRevision: plan.revision,
|
|
579
|
+
intents: plan.intents,
|
|
580
|
+
errors: plan.preliminaryErrors,
|
|
581
|
+
capability: {
|
|
582
|
+
egressIdentityProof: plan.egressIdentitySupported ? 'supported' : 'unsupported',
|
|
583
|
+
selectorCorrectSigning: plan.selectorCorrectSigningSupported ? 'supported' : 'unsupported',
|
|
584
|
+
},
|
|
585
|
+
lastAttemptAt: new Date().toISOString(),
|
|
586
|
+
retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString(),
|
|
587
|
+
};
|
|
588
|
+
plan.doc.updatedAt = new Date().toISOString();
|
|
589
|
+
await plan.doc.save();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private async persistDeletingAttempt(doc: EmailDomainDoc): Promise<void> {
|
|
593
|
+
doc.reconciliation = {
|
|
594
|
+
...(doc.reconciliation || {
|
|
595
|
+
lifecycleStatus: 'deleting',
|
|
596
|
+
desiredGeneration: 1,
|
|
597
|
+
intents: [],
|
|
598
|
+
errors: [],
|
|
599
|
+
}),
|
|
600
|
+
lifecycleStatus: 'deleting',
|
|
601
|
+
intents: [],
|
|
602
|
+
errors: (doc.reconciliation?.errors || []).filter((error) => error.code !== 'DNS_CLEANUP_FAILED'),
|
|
603
|
+
lastAttemptAt: new Date().toISOString(),
|
|
604
|
+
retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString(),
|
|
605
|
+
};
|
|
606
|
+
await doc.save();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private async reconcileZones(
|
|
610
|
+
intents: IEmailDnsRecordIntent[],
|
|
611
|
+
zones: DomainDoc[],
|
|
612
|
+
): Promise<Map<string, IEmailDomainOperationError>> {
|
|
613
|
+
const errors = new Map<string, IEmailDomainOperationError>();
|
|
614
|
+
const byZone = new Map<string, IEmailDnsRecordIntent[]>();
|
|
615
|
+
for (const intent of intents) {
|
|
616
|
+
if (!intent.domainId || intent.error) continue;
|
|
617
|
+
const group = byZone.get(intent.domainId) || [];
|
|
618
|
+
group.push(intent);
|
|
619
|
+
byZone.set(intent.domainId, group);
|
|
620
|
+
}
|
|
621
|
+
for (const [zoneId, zoneIntents] of byZone) {
|
|
622
|
+
const zone = zones.find((candidate) => candidate.id === zoneId);
|
|
623
|
+
if (!zone) continue;
|
|
624
|
+
try {
|
|
625
|
+
if (zone.source === 'provider') {
|
|
626
|
+
const result = await this.dcRouterRef.dnsManager!.syncDomain(zone.id);
|
|
627
|
+
if (!result.success) throw new Error(result.message || `Failed to refresh ${zone.name}`);
|
|
628
|
+
}
|
|
629
|
+
for (const intent of this.sortIntents(zoneIntents)) {
|
|
630
|
+
await this.reconcileIntent(intent, zoneIntents);
|
|
631
|
+
}
|
|
632
|
+
} catch (error: unknown) {
|
|
633
|
+
const outcome: IEmailDomainOperationError = {
|
|
634
|
+
code: 'DNS_PROVIDER_ZONE_FAILED',
|
|
635
|
+
message: `DNS zone ${zone.name} reconciliation failed: ${(error as Error).message}`,
|
|
636
|
+
retryable: true,
|
|
637
|
+
};
|
|
638
|
+
errors.set(zone.id, outcome);
|
|
639
|
+
for (const intent of zoneIntents) {
|
|
640
|
+
intent.status = 'failed';
|
|
641
|
+
intent.error = { ...outcome, recordKey: intent.key };
|
|
642
|
+
}
|
|
143
643
|
}
|
|
644
|
+
}
|
|
645
|
+
return errors;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private sortIntents(intents: IEmailDnsRecordIntent[]): IEmailDnsRecordIntent[] {
|
|
649
|
+
const rank = (intent: IEmailDnsRecordIntent): number => {
|
|
650
|
+
if (intent.type === 'A' || intent.type === 'AAAA') return 0;
|
|
651
|
+
if (intent.type === 'TXT' && intent.name.includes('._domainkey.')) return 1;
|
|
652
|
+
if (intent.type === 'TXT') return 2;
|
|
653
|
+
return 3; // MX is exposed only after identity/auth records exist.
|
|
144
654
|
};
|
|
655
|
+
return [...intents].sort((left, right) => rank(left) - rank(right) || left.key.localeCompare(right.key));
|
|
656
|
+
}
|
|
145
657
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
658
|
+
private async reconcileIntent(
|
|
659
|
+
intent: IEmailDnsRecordIntent,
|
|
660
|
+
zoneIntents: IEmailDnsRecordIntent[],
|
|
661
|
+
): Promise<void> {
|
|
662
|
+
const records = await DnsRecordDoc.findByDomainId(intent.domainId);
|
|
663
|
+
const rrset = records.filter((record) => (
|
|
664
|
+
record.name.toLowerCase() === intent.name.toLowerCase() && record.type === intent.type
|
|
665
|
+
));
|
|
666
|
+
const exact = rrset.filter((record) => this.valuesEqual(intent.type, record.value, intent.value));
|
|
667
|
+
const desiredValues = zoneIntents
|
|
668
|
+
.filter((candidate) => candidate.name.toLowerCase() === intent.name.toLowerCase() && candidate.type === intent.type)
|
|
669
|
+
.map((candidate) => candidate.value);
|
|
670
|
+
const unmanaged = rrset.filter((record) => (
|
|
671
|
+
record.managedBy !== MAIL_DNS_MANAGED_BY && this.isRelevantUnmanagedTxt(intent, record.value)
|
|
672
|
+
));
|
|
673
|
+
const exactUnmanaged = exact.filter((record) => record.managedBy !== MAIL_DNS_MANAGED_BY);
|
|
674
|
+
const conflictingUnmanaged = unmanaged.filter((record) => (
|
|
675
|
+
!desiredValues.some((desired) => this.valuesEqual(intent.type, record.value, desired))
|
|
676
|
+
));
|
|
677
|
+
if (conflictingUnmanaged.length > 0 || (intent.type === 'TXT' && exactUnmanaged.length > 1)) {
|
|
678
|
+
intent.status = 'conflict';
|
|
679
|
+
intent.error = {
|
|
680
|
+
code: 'DNS_UNMANAGED_CONFLICT',
|
|
681
|
+
message: `Unmanaged ${intent.type} record conflicts with ${intent.name}`,
|
|
682
|
+
recordKey: intent.key,
|
|
683
|
+
retryable: false,
|
|
684
|
+
};
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (exactUnmanaged.length === 1) {
|
|
688
|
+
// Exact operator record satisfies the intent without adoption/deletion.
|
|
689
|
+
// Remove only a dcrouter-owned duplicate of this same intent; other MX
|
|
690
|
+
// values in the desired RRset remain independently managed.
|
|
691
|
+
const ownedDuplicates = rrset.filter((record) => (
|
|
692
|
+
record.managedBy === MAIL_DNS_MANAGED_BY
|
|
693
|
+
&& (
|
|
694
|
+
record.managedRecordKey === intent.key
|
|
695
|
+
|| this.valuesEqual(intent.type, record.value, intent.value)
|
|
696
|
+
)
|
|
697
|
+
));
|
|
698
|
+
for (const duplicate of ownedDuplicates) {
|
|
699
|
+
const result = await this.dcRouterRef.dnsManager!.deleteRecord(duplicate.id);
|
|
700
|
+
if (!result.success) throw new Error(result.message || `Failed to delete duplicate ${intent.key}`);
|
|
701
|
+
}
|
|
702
|
+
intent.status = 'satisfied';
|
|
703
|
+
return;
|
|
151
704
|
}
|
|
152
705
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
706
|
+
let managed = rrset.find((record) => record.managedRecordKey === intent.key);
|
|
707
|
+
if (!managed) {
|
|
708
|
+
const adoptable = exact.find((record) => record.managedBy === MAIL_DNS_MANAGED_BY);
|
|
709
|
+
if (adoptable) {
|
|
710
|
+
adoptable.managedOwnerId = intent.ownerId;
|
|
711
|
+
adoptable.managedRecordKey = intent.key;
|
|
712
|
+
await adoptable.save();
|
|
713
|
+
managed = adoptable;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (managed) {
|
|
717
|
+
if (!this.valuesEqual(intent.type, managed.value, intent.value) || managed.name !== intent.name) {
|
|
718
|
+
const result = await this.dcRouterRef.dnsManager!.updateRecord({
|
|
719
|
+
id: managed.id,
|
|
720
|
+
name: intent.name,
|
|
721
|
+
value: intent.value,
|
|
722
|
+
ttl: intent.ttl,
|
|
723
|
+
});
|
|
724
|
+
if (!result.success) throw new Error(result.message || `Failed to update ${intent.key}`);
|
|
725
|
+
}
|
|
726
|
+
intent.providerRecordId = managed.providerRecordId;
|
|
727
|
+
intent.status = 'satisfied';
|
|
728
|
+
intent.lastAttemptAt = new Date().toISOString();
|
|
729
|
+
return;
|
|
157
730
|
}
|
|
158
731
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
732
|
+
const result = await this.dcRouterRef.dnsManager!.createRecord({
|
|
733
|
+
domainId: intent.domainId,
|
|
734
|
+
name: intent.name,
|
|
735
|
+
type: intent.type,
|
|
736
|
+
value: intent.value,
|
|
737
|
+
ttl: intent.ttl,
|
|
738
|
+
createdBy: MAIL_DNS_SYNC_CREATED_BY,
|
|
739
|
+
managedBy: MAIL_DNS_MANAGED_BY,
|
|
740
|
+
managedOwnerId: intent.ownerId,
|
|
741
|
+
managedRecordKey: intent.key,
|
|
742
|
+
});
|
|
743
|
+
if (!result.success) throw new Error(result.message || `Failed to create ${intent.key}`);
|
|
744
|
+
intent.status = 'satisfied';
|
|
745
|
+
intent.lastAttemptAt = new Date().toISOString();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private valuesEqual(type: TDnsRecordType, left: string, right: string): boolean {
|
|
749
|
+
if (type === 'MX') return normalizeMx(left) === normalizeMx(right);
|
|
750
|
+
if (type === 'A' || type === 'AAAA') return left.trim() === right.trim();
|
|
751
|
+
return left.trim() === right.trim();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private isRelevantUnmanagedTxt(intent: IEmailDnsRecordIntent, value: string): boolean {
|
|
755
|
+
if (intent.type !== 'TXT') return true;
|
|
756
|
+
const normalizedIntent = intent.value.trim().toLowerCase();
|
|
757
|
+
const normalizedValue = value.trim().toLowerCase();
|
|
758
|
+
if (normalizedIntent.startsWith('v=spf1')) return normalizedValue.startsWith('v=spf1');
|
|
759
|
+
if (normalizedIntent.startsWith('v=dmarc1')) return normalizedValue.startsWith('v=dmarc1');
|
|
760
|
+
if (normalizedIntent.startsWith('v=dkim1')) return normalizedValue.startsWith('v=dkim1');
|
|
761
|
+
return this.valuesEqual(intent.type, value, intent.value);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private async validateAndActivate(
|
|
765
|
+
plan: IPlannedDomain,
|
|
766
|
+
errors: IEmailDomainOperationError[],
|
|
767
|
+
zoneErrors: Map<string, IEmailDomainOperationError>,
|
|
768
|
+
): Promise<void> {
|
|
769
|
+
const verifiable = plan.intents.filter((intent) => (
|
|
770
|
+
!intent.error && !zoneErrors.has(intent.domainId)
|
|
771
|
+
));
|
|
772
|
+
const markResult = (intent: IEmailDnsRecordIntent, result: IMailDnsVerificationResult) => {
|
|
773
|
+
if (result.valid) {
|
|
774
|
+
intent.status = 'valid';
|
|
775
|
+
intent.validatedAt = new Date().toISOString();
|
|
776
|
+
intent.error = undefined;
|
|
777
|
+
} else {
|
|
778
|
+
intent.status = 'pending';
|
|
779
|
+
intent.error = {
|
|
780
|
+
code: 'DNS_PROPAGATION_PENDING',
|
|
781
|
+
message: result.reason || `${intent.type} ${intent.name} has not propagated`,
|
|
782
|
+
recordKey: intent.key,
|
|
783
|
+
retryable: true,
|
|
784
|
+
};
|
|
168
785
|
}
|
|
169
|
-
|
|
170
|
-
|
|
786
|
+
};
|
|
787
|
+
const mxByName = new Map<string, IEmailDnsRecordIntent[]>();
|
|
788
|
+
for (const intent of verifiable.filter((candidate) => candidate.type === 'MX')) {
|
|
789
|
+
const group = mxByName.get(intent.name) || [];
|
|
790
|
+
group.push(intent);
|
|
791
|
+
mxByName.set(intent.name, group);
|
|
792
|
+
}
|
|
793
|
+
await Promise.all([
|
|
794
|
+
...verifiable.filter((intent) => intent.type !== 'MX').map(async (intent) => {
|
|
795
|
+
const result = await this.verifier.verifyRecord(intent)
|
|
796
|
+
.catch((error: unknown) => ({ valid: false, reason: (error as Error).message }));
|
|
797
|
+
markResult(intent, result);
|
|
798
|
+
}),
|
|
799
|
+
...[...mxByName.values()].map(async (intents) => {
|
|
800
|
+
const previousMx = (plan.doc.reconciliation?.activeRevision?.mxSlots || [])
|
|
801
|
+
.map((slot) => `${slot.priority} ${slot.hostname}`).sort();
|
|
802
|
+
const desiredMx = intents.map((intent) => normalizeMx(intent.value)).sort();
|
|
803
|
+
const isMakeBeforeBreakTransition = previousMx.length > 0
|
|
804
|
+
&& JSON.stringify(previousMx) !== JSON.stringify(desiredMx);
|
|
805
|
+
const result = await (this.verifier.verifyMxSet && !isMakeBeforeBreakTransition
|
|
806
|
+
? this.verifier.verifyMxSet(intents)
|
|
807
|
+
: Promise.all(intents.map((intent) => this.verifier.verifyRecord(intent))).then((results) => ({
|
|
808
|
+
valid: results.every((entry) => entry.valid),
|
|
809
|
+
reason: results.find((entry) => !entry.valid)?.reason,
|
|
810
|
+
})))
|
|
811
|
+
.catch((error: unknown) => ({ valid: false, reason: (error as Error).message }));
|
|
812
|
+
for (const intent of intents) markResult(intent, result);
|
|
813
|
+
}),
|
|
814
|
+
]);
|
|
815
|
+
for (const intent of plan.intents) {
|
|
816
|
+
if (intent.error && !errors.some((error) => error.code === intent.error!.code && error.recordKey === intent.key)) {
|
|
817
|
+
errors.push(intent.error);
|
|
171
818
|
}
|
|
172
819
|
}
|
|
173
820
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
821
|
+
if (plan.egressIdentitySupported && plan.revision.edgeIdentities.length > 0) {
|
|
822
|
+
const identityResults = await Promise.all(plan.revision.edgeIdentities.map(async (identity) => ({
|
|
823
|
+
identity,
|
|
824
|
+
result: await this.verifier.verifyForwardConfirmedIdentity(identity)
|
|
825
|
+
.catch((error: unknown) => ({ valid: false, reason: (error as Error).message })),
|
|
826
|
+
})));
|
|
827
|
+
for (const { identity, result } of identityResults) {
|
|
828
|
+
if (!result.valid) {
|
|
829
|
+
errors.push({
|
|
830
|
+
code: 'FCRDNS_INVALID',
|
|
831
|
+
message: result.reason || `FCrDNS validation failed for ${identity.edgeId}`,
|
|
832
|
+
retryable: true,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
182
835
|
}
|
|
183
|
-
|
|
184
|
-
});
|
|
836
|
+
}
|
|
185
837
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
838
|
+
const canActivate = plan.egressIdentitySupported
|
|
839
|
+
&& plan.revision.edgeIdentities.length > 0
|
|
840
|
+
&& (plan.revision.edgeIdentities.length >= 2
|
|
841
|
+
? plan.revision.mxSlots.length === 2
|
|
842
|
+
: plan.revision.mxSlots.length === 1)
|
|
843
|
+
&& plan.selectorCorrectSigningSupported
|
|
844
|
+
&& Boolean(this.getActiveDkim(plan.doc))
|
|
845
|
+
&& plan.intents.length > 0
|
|
846
|
+
&& plan.intents.every((intent) => intent.status === 'valid')
|
|
847
|
+
&& errors.length === 0;
|
|
848
|
+
const hasPermanentError = errors.some((error) => error.retryable === false)
|
|
849
|
+
|| errors.some((error) => ['DNS_PROVIDER_ZONE_FAILED', 'DNS_UNMANAGED_CONFLICT'].includes(error.code));
|
|
850
|
+
const previousActive = plan.doc.reconciliation?.activeRevision;
|
|
851
|
+
const topologyChanged = Boolean(previousActive) && JSON.stringify({
|
|
852
|
+
selector: previousActive?.selector,
|
|
853
|
+
edgeIdentities: previousActive?.edgeIdentities,
|
|
854
|
+
mxSlots: previousActive?.mxSlots,
|
|
855
|
+
}) !== JSON.stringify({
|
|
856
|
+
selector: plan.revision.selector,
|
|
857
|
+
edgeIdentities: plan.revision.edgeIdentities,
|
|
858
|
+
mxSlots: plan.revision.mxSlots,
|
|
193
859
|
});
|
|
860
|
+
const now = new Date().toISOString();
|
|
861
|
+
if (canActivate && plan.doc.pendingDkim) {
|
|
862
|
+
this.promotePendingDkim(plan.doc, now);
|
|
863
|
+
}
|
|
864
|
+
plan.doc.reconciliation = {
|
|
865
|
+
lifecycleStatus: canActivate ? 'active' : hasPermanentError ? 'failed' : 'pending',
|
|
866
|
+
desiredGeneration: plan.doc.reconciliation?.desiredGeneration || 1,
|
|
867
|
+
activeRevision: canActivate
|
|
868
|
+
? { ...plan.revision, activatedAt: now }
|
|
869
|
+
: plan.doc.reconciliation?.activeRevision,
|
|
870
|
+
pendingRevision: canActivate ? undefined : plan.revision,
|
|
871
|
+
intents: plan.intents,
|
|
872
|
+
errors,
|
|
873
|
+
capability: {
|
|
874
|
+
egressIdentityProof: plan.egressIdentitySupported ? 'supported' : 'unsupported',
|
|
875
|
+
selectorCorrectSigning: plan.selectorCorrectSigningSupported ? 'supported' : 'unsupported',
|
|
876
|
+
},
|
|
877
|
+
lastAttemptAt: now,
|
|
878
|
+
...(canActivate ? { lastSuccessAt: now } : { retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString() }),
|
|
879
|
+
};
|
|
880
|
+
plan.doc.dnsStatus = {
|
|
881
|
+
mx: this.statusForIntent(plan.intents, (intent) => intent.type === 'MX'),
|
|
882
|
+
spf: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1')),
|
|
883
|
+
dkim: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
|
|
884
|
+
dmarc: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
|
|
885
|
+
lastCheckedAt: now,
|
|
886
|
+
};
|
|
887
|
+
plan.doc.updatedAt = now;
|
|
888
|
+
await plan.doc.save();
|
|
889
|
+
if (canActivate && topologyChanged) {
|
|
890
|
+
// The transition SPF intentionally authorizes old + new identities. Run
|
|
891
|
+
// one immediate follow-up to contract it to the newly active revision.
|
|
892
|
+
this.dirty = true;
|
|
893
|
+
}
|
|
194
894
|
}
|
|
195
895
|
|
|
196
|
-
private
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
896
|
+
private promotePendingDkim(doc: EmailDomainDoc, now: string): void {
|
|
897
|
+
const pending = doc.pendingDkim;
|
|
898
|
+
if (!pending) return;
|
|
899
|
+
const previous = this.getActiveDkim(doc);
|
|
900
|
+
const overlapDays = Math.max(1, doc.dkim.retirementOverlapDays || 30);
|
|
901
|
+
const retiring = (doc.retiringDkim || []).filter((material) => (
|
|
902
|
+
material.selector !== pending.selector
|
|
903
|
+
&& (!material.retireAfter || Date.parse(material.retireAfter) > Date.now())
|
|
904
|
+
));
|
|
905
|
+
if (previous && previous.selector !== pending.selector) {
|
|
906
|
+
retiring.push({
|
|
907
|
+
...previous,
|
|
908
|
+
retireAfter: new Date(Date.now() + overlapDays * 86_400_000).toISOString(),
|
|
909
|
+
});
|
|
201
910
|
}
|
|
202
|
-
|
|
911
|
+
doc.activeDkim = { ...pending, validatedAt: now, promotedAt: now };
|
|
912
|
+
doc.pendingDkim = undefined;
|
|
913
|
+
doc.retiringDkim = retiring;
|
|
914
|
+
doc.dkim.selector = pending.selector;
|
|
915
|
+
doc.dkim.keySize = pending.keySize;
|
|
916
|
+
doc.dkim.publicKey = pending.publicKey;
|
|
917
|
+
doc.dkim.lastRotatedAt = now;
|
|
203
918
|
}
|
|
204
919
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
920
|
+
private statusForIntent(
|
|
921
|
+
intents: IEmailDnsRecordIntent[],
|
|
922
|
+
predicate: (intent: IEmailDnsRecordIntent) => boolean,
|
|
923
|
+
): 'valid' | 'missing' | 'invalid' | 'unchecked' {
|
|
924
|
+
const selected = intents.filter(predicate);
|
|
925
|
+
if (selected.length === 0) return 'missing';
|
|
926
|
+
if (selected.every((intent) => intent.status === 'valid')) return 'valid';
|
|
927
|
+
if (selected.some((intent) => intent.status === 'conflict' || intent.status === 'failed')) return 'invalid';
|
|
928
|
+
return 'missing';
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private async cleanupStaleManagedRecords(zones: DomainDoc[]): Promise<void> {
|
|
932
|
+
const docs = await EmailDomainDoc.findAll();
|
|
933
|
+
const keep = new Set<string>();
|
|
934
|
+
for (const doc of docs) {
|
|
935
|
+
if (doc.reconciliation?.lifecycleStatus === 'deleting') continue;
|
|
936
|
+
for (const intent of doc.reconciliation?.intents || []) keep.add(intent.key);
|
|
937
|
+
if (doc.reconciliation?.lifecycleStatus !== 'active') {
|
|
938
|
+
for (const key of doc.reconciliation?.activeRevision?.recordKeys || []) keep.add(key);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const managed = (await DnsRecordDoc.findAll()).filter((record) => record.managedBy === MAIL_DNS_MANAGED_BY);
|
|
942
|
+
for (const record of managed) {
|
|
943
|
+
if (record.managedRecordKey && keep.has(record.managedRecordKey)) continue;
|
|
944
|
+
try {
|
|
945
|
+
const result = await this.dcRouterRef.dnsManager!.deleteRecord(record.id);
|
|
946
|
+
if (!result.success) throw new Error(result.message || 'delete failed');
|
|
947
|
+
} catch (error: unknown) {
|
|
948
|
+
const ownerId = record.managedOwnerId?.replace(/^domain:/, '');
|
|
949
|
+
const owner = ownerId ? docs.find((doc) => doc.id === ownerId) : undefined;
|
|
950
|
+
if (owner?.reconciliation) {
|
|
951
|
+
owner.reconciliation.errors.push({
|
|
952
|
+
code: 'DNS_CLEANUP_FAILED',
|
|
953
|
+
message: `Failed to delete ${record.type} ${record.name}: ${(error as Error).message}`,
|
|
954
|
+
recordKey: record.managedRecordKey,
|
|
955
|
+
retryable: true,
|
|
956
|
+
});
|
|
957
|
+
owner.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
|
|
958
|
+
await owner.save();
|
|
213
959
|
}
|
|
214
960
|
}
|
|
215
961
|
}
|
|
216
|
-
return best;
|
|
217
962
|
}
|
|
963
|
+
|
|
964
|
+
private async finalizeDeletingDomains(): Promise<void> {
|
|
965
|
+
const docs = await EmailDomainDoc.findAll();
|
|
966
|
+
const records = await DnsRecordDoc.findAll();
|
|
967
|
+
for (const doc of docs) {
|
|
968
|
+
if (doc.reconciliation?.lifecycleStatus !== 'deleting') continue;
|
|
969
|
+
const owned = records.some((record) => (
|
|
970
|
+
record.managedBy === MAIL_DNS_MANAGED_BY && record.managedOwnerId === `domain:${doc.id}`
|
|
971
|
+
));
|
|
972
|
+
if (owned || doc.reconciliation.errors.some((error) => error.code === 'DNS_CLEANUP_FAILED')) continue;
|
|
973
|
+
await doc.delete();
|
|
974
|
+
logger.log('info', `Email domain deletion finalized: ${doc.domain}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
private findZoneForName(name: string, zones: DomainDoc[]): DomainDoc | undefined {
|
|
979
|
+
const normalized = normalizeHostname(name);
|
|
980
|
+
return [...zones]
|
|
981
|
+
.filter((zone) => normalized === normalizeHostname(zone.name) || normalized.endsWith(`.${normalizeHostname(zone.name)}`))
|
|
982
|
+
.sort((left, right) => right.name.length - left.name.length)[0];
|
|
983
|
+
}
|
|
984
|
+
|
|
218
985
|
}
|