@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
|
@@ -13,6 +13,16 @@ import {
|
|
|
13
13
|
encodeCloudflareTxtContent,
|
|
14
14
|
} from '../txt-record-presentation.js';
|
|
15
15
|
|
|
16
|
+
interface ICloudflareApiRecord {
|
|
17
|
+
id?: unknown;
|
|
18
|
+
name?: unknown;
|
|
19
|
+
type?: unknown;
|
|
20
|
+
content?: unknown;
|
|
21
|
+
ttl?: unknown;
|
|
22
|
+
priority?: unknown;
|
|
23
|
+
proxied?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
/**
|
|
17
27
|
* Cloudflare implementation of IDnsProviderClient.
|
|
18
28
|
*
|
|
@@ -23,6 +33,7 @@ import {
|
|
|
23
33
|
*/
|
|
24
34
|
export class CloudflareDnsProvider implements IDnsProviderClient {
|
|
25
35
|
private cfAccount: plugins.cloudflare.CloudflareAccount;
|
|
36
|
+
private recordListingPromises = new Map<string, Promise<ICloudflareApiRecord[]>>();
|
|
26
37
|
|
|
27
38
|
constructor(apiToken: string) {
|
|
28
39
|
if (!apiToken) {
|
|
@@ -53,23 +64,37 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
|
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
public async listRecords(domain: string): Promise<IProviderRecord[]> {
|
|
56
|
-
const
|
|
57
|
-
const records: any[] = [];
|
|
58
|
-
// RecordManager.listRecords() intentionally converts provider failures to
|
|
59
|
-
// an empty array. DNS reconciliation must distinguish an empty zone from
|
|
60
|
-
// an unavailable provider, so use the raw paginated iterator and let
|
|
61
|
-
// errors propagate to the durable reconciliation outcome.
|
|
62
|
-
for await (const record of (this.cfAccount as any).apiAccount.dns.records.list({
|
|
63
|
-
zone_id: zoneId,
|
|
64
|
-
per_page: 100,
|
|
65
|
-
})) {
|
|
66
|
-
records.push(record);
|
|
67
|
-
}
|
|
67
|
+
const records = await this.listApiRecords(domain);
|
|
68
68
|
return records
|
|
69
|
-
.filter((
|
|
69
|
+
.filter((record) => typeof record.type === 'string' && this.isSupportedType(record.type))
|
|
70
70
|
.map((r) => this.fromApiRecord(r));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
public async listRecordIds(domain: string): Promise<string[]> {
|
|
74
|
+
return (await this.listApiRecords(domain)).map((record) => {
|
|
75
|
+
const providerRecordId = typeof record?.id === 'string' ? record.id.trim() : '';
|
|
76
|
+
if (!providerRecordId) {
|
|
77
|
+
throw new Error('CloudflareDnsProvider: provider response has no record id');
|
|
78
|
+
}
|
|
79
|
+
return providerRecordId;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async listApiRecords(domain: string): Promise<ICloudflareApiRecord[]> {
|
|
84
|
+
const key = domain.trim().toLowerCase();
|
|
85
|
+
const pending = this.recordListingPromises.get(key);
|
|
86
|
+
if (pending) return await pending;
|
|
87
|
+
const listing = this.cfAccount.recordManager.listRecords(domain);
|
|
88
|
+
this.recordListingPromises.set(key, listing);
|
|
89
|
+
try {
|
|
90
|
+
return await listing;
|
|
91
|
+
} finally {
|
|
92
|
+
if (this.recordListingPromises.get(key) === listing) {
|
|
93
|
+
this.recordListingPromises.delete(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
73
98
|
public async createRecord(
|
|
74
99
|
domain: string,
|
|
75
100
|
record: IProviderRecordInput,
|
|
@@ -85,7 +110,7 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
|
|
|
85
110
|
if (record.proxied !== undefined) {
|
|
86
111
|
apiRecord.proxied = record.proxied;
|
|
87
112
|
}
|
|
88
|
-
const created = await
|
|
113
|
+
const created = await this.cfAccount.apiAccount.dns.records.create(apiRecord);
|
|
89
114
|
return this.fromApiRecord(created);
|
|
90
115
|
}
|
|
91
116
|
|
|
@@ -105,7 +130,7 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
|
|
|
105
130
|
if (record.proxied !== undefined) {
|
|
106
131
|
apiRecord.proxied = record.proxied;
|
|
107
132
|
}
|
|
108
|
-
const updated = await
|
|
133
|
+
const updated = await this.cfAccount.apiAccount.dns.records.edit(
|
|
109
134
|
providerRecordId,
|
|
110
135
|
apiRecord,
|
|
111
136
|
);
|
|
@@ -114,7 +139,7 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
|
|
|
114
139
|
|
|
115
140
|
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
|
|
116
141
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
|
117
|
-
await
|
|
142
|
+
await this.cfAccount.apiAccount.dns.records.delete(providerRecordId, {
|
|
118
143
|
zone_id: zoneId,
|
|
119
144
|
});
|
|
120
145
|
}
|
|
@@ -130,7 +155,7 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
|
|
|
130
155
|
if (record.type !== 'MX') {
|
|
131
156
|
return { content: record.value };
|
|
132
157
|
}
|
|
133
|
-
const match = record.value.trim().match(/^(\d
|
|
158
|
+
const match = record.value.trim().match(/^(\d{1,5})\s+([^\s]+)$/);
|
|
134
159
|
if (!match) {
|
|
135
160
|
throw new Error(`CloudflareDnsProvider: invalid MX value "${record.value}"; expected "<priority> <exchange>"`);
|
|
136
161
|
}
|
|
@@ -138,14 +163,41 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
|
|
|
138
163
|
if (!Number.isInteger(priority) || priority < 0 || priority > 65535) {
|
|
139
164
|
throw new Error(`CloudflareDnsProvider: invalid MX priority in "${record.value}"`);
|
|
140
165
|
}
|
|
141
|
-
|
|
166
|
+
const exchange = match[2];
|
|
167
|
+
if (!this.isValidMxExchange(exchange, priority)) {
|
|
168
|
+
throw new Error(`CloudflareDnsProvider: invalid MX exchange in "${record.value}"`);
|
|
169
|
+
}
|
|
170
|
+
return { content: exchange, priority };
|
|
142
171
|
}
|
|
143
172
|
|
|
144
|
-
private fromApiRecord(record:
|
|
173
|
+
private fromApiRecord(record: ICloudflareApiRecord): IProviderRecord {
|
|
174
|
+
const providerRecordId = typeof record?.id === 'string' ? record.id.trim() : '';
|
|
175
|
+
const name = typeof record?.name === 'string' ? record.name.trim() : '';
|
|
176
|
+
const type = typeof record?.type === 'string' ? record.type : '';
|
|
177
|
+
const ttl = Number(record?.ttl);
|
|
178
|
+
if (!providerRecordId) throw new Error('CloudflareDnsProvider: provider response has no record id');
|
|
179
|
+
if (!name) throw new Error('CloudflareDnsProvider: provider response has no record name');
|
|
180
|
+
if (!this.isSupportedType(type)) {
|
|
181
|
+
throw new Error(`CloudflareDnsProvider: provider response has unsupported record type ${type || '<empty>'}`);
|
|
182
|
+
}
|
|
183
|
+
if (!Number.isInteger(ttl) || ttl < 1) {
|
|
184
|
+
throw new Error(`CloudflareDnsProvider: provider response has invalid TTL ${String(record?.ttl)}`);
|
|
185
|
+
}
|
|
186
|
+
if (typeof record?.content !== 'string' || (type !== 'TXT' && !record.content.trim())) {
|
|
187
|
+
throw new Error(`CloudflareDnsProvider: provider response has invalid content for ${type} ${name}`);
|
|
188
|
+
}
|
|
145
189
|
let value: string;
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
190
|
+
if (type === 'MX') {
|
|
191
|
+
const priority = Number(record.priority);
|
|
192
|
+
const exchange = record.content.trim();
|
|
193
|
+
if (!Number.isInteger(priority) || priority < 0 || priority > 65_535) {
|
|
194
|
+
throw new Error(`CloudflareDnsProvider: provider response has invalid MX priority ${String(record.priority)}`);
|
|
195
|
+
}
|
|
196
|
+
if (!this.isValidMxExchange(exchange, priority)) {
|
|
197
|
+
throw new Error(`CloudflareDnsProvider: provider response has invalid MX exchange ${exchange}`);
|
|
198
|
+
}
|
|
199
|
+
value = `${priority} ${exchange}`;
|
|
200
|
+
} else if (type === 'TXT') {
|
|
149
201
|
const decoded = decodeTxtRecordContent(String(record.content));
|
|
150
202
|
if (!decoded.ok) {
|
|
151
203
|
throw new Error(
|
|
@@ -157,12 +209,27 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
|
|
|
157
209
|
value = String(record.content);
|
|
158
210
|
}
|
|
159
211
|
return {
|
|
160
|
-
providerRecordId
|
|
161
|
-
name
|
|
162
|
-
type:
|
|
212
|
+
providerRecordId,
|
|
213
|
+
name,
|
|
214
|
+
type: type as TDnsRecordType,
|
|
163
215
|
value,
|
|
164
|
-
ttl
|
|
216
|
+
ttl,
|
|
165
217
|
...(record.proxied !== undefined ? { proxied: Boolean(record.proxied) } : {}),
|
|
166
218
|
};
|
|
167
219
|
}
|
|
220
|
+
|
|
221
|
+
private isValidHostname(value: string): boolean {
|
|
222
|
+
const hostname = value.toLowerCase().replace(/\.$/, '');
|
|
223
|
+
return hostname.length > 0
|
|
224
|
+
&& hostname.length <= 253
|
|
225
|
+
&& hostname.split('.').every((label) => (
|
|
226
|
+
label.length > 0
|
|
227
|
+
&& label.length <= 63
|
|
228
|
+
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private isValidMxExchange(value: string, priority: number): boolean {
|
|
233
|
+
return value === '.' ? priority === 0 : this.isValidHostname(value);
|
|
234
|
+
}
|
|
168
235
|
}
|
|
@@ -52,6 +52,9 @@ export interface IDnsProviderClient {
|
|
|
52
52
|
/** List all DNS records for a zone (FQDN). */
|
|
53
53
|
listRecords(domain: string): Promise<IProviderRecord[]>;
|
|
54
54
|
|
|
55
|
+
/** List every provider record id, including record types dcrouter does not mirror. */
|
|
56
|
+
listRecordIds(domain: string): Promise<string[]>;
|
|
57
|
+
|
|
55
58
|
/** Create a new DNS record at the provider; returns the created record (with id). */
|
|
56
59
|
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
|
|
57
60
|
|
|
@@ -166,6 +166,7 @@ export class EmailDomainManager {
|
|
|
166
166
|
throw new Error('DKIM key creator is not ready; email domain was not persisted');
|
|
167
167
|
}
|
|
168
168
|
const keySize = opts.dkimKeySize || 2048;
|
|
169
|
+
const rotationIntervalDays = this.normalizeRotationIntervalDays(opts.rotationIntervalDays ?? 90);
|
|
169
170
|
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
|
170
171
|
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
|
171
172
|
const publicKey = this.extractDkimPublicKey(dnsRecord?.value);
|
|
@@ -190,7 +191,7 @@ export class EmailDomainManager {
|
|
|
190
191
|
keySize,
|
|
191
192
|
publicKey,
|
|
192
193
|
rotateKeys: opts.rotateKeys ?? false,
|
|
193
|
-
rotationIntervalDays
|
|
194
|
+
rotationIntervalDays,
|
|
194
195
|
retirementOverlapDays: 30,
|
|
195
196
|
};
|
|
196
197
|
doc.activeDkim = activeDkim;
|
|
@@ -256,7 +257,9 @@ export class EmailDomainManager {
|
|
|
256
257
|
};
|
|
257
258
|
}
|
|
258
259
|
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
|
|
259
|
-
if (changes.rotationIntervalDays !== undefined)
|
|
260
|
+
if (changes.rotationIntervalDays !== undefined) {
|
|
261
|
+
doc.dkim.rotationIntervalDays = this.normalizeRotationIntervalDays(changes.rotationIntervalDays);
|
|
262
|
+
}
|
|
260
263
|
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
|
261
264
|
if (changes.remoteIngress !== undefined) {
|
|
262
265
|
doc.remoteIngress = changes.remoteIngress === null
|
|
@@ -343,7 +346,7 @@ export class EmailDomainManager {
|
|
|
343
346
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
|
344
347
|
const provisioned = ticket.records
|
|
345
348
|
.filter((record) => record.managedBy === 'mail-dns-reconciler' && !before.has(record.id)).length;
|
|
346
|
-
return { ...this.resultForDoc(doc,
|
|
349
|
+
return { ...this.resultForDoc(doc, !this.hasDnsMutationFailure(doc)), provisioned };
|
|
347
350
|
}
|
|
348
351
|
|
|
349
352
|
public async validateDns(id: string): Promise<IEmailDomainManagerActionResult> {
|
|
@@ -359,7 +362,7 @@ export class EmailDomainManager {
|
|
|
359
362
|
status: projectMailDnsIntentStatus(intent),
|
|
360
363
|
}));
|
|
361
364
|
return {
|
|
362
|
-
...this.resultForDoc(doc,
|
|
365
|
+
...this.resultForDoc(doc, !this.hasDnsMutationFailure(doc)),
|
|
363
366
|
domain: this.docToInterface(doc),
|
|
364
367
|
records,
|
|
365
368
|
};
|
|
@@ -385,15 +388,37 @@ export class EmailDomainManager {
|
|
|
385
388
|
return { ready: false, reason: 'SmartMTA selector-correct signing capability is unavailable', edgeIds: [] };
|
|
386
389
|
}
|
|
387
390
|
const reconciliation = doc.reconciliation;
|
|
391
|
+
if (reconciliation?.lifecycleStatus !== 'active') {
|
|
392
|
+
return { ready: false, reason: 'managed email domain DNS lifecycle is not active', edgeIds: [] };
|
|
393
|
+
}
|
|
394
|
+
if (reconciliation.errors.length > 0) {
|
|
395
|
+
return { ready: false, reason: reconciliation.errors[0].message, edgeIds: [] };
|
|
396
|
+
}
|
|
397
|
+
if (!await DomainDoc.findById(doc.linkedDomainId)) {
|
|
398
|
+
return { ready: false, reason: 'linked DNS domain is missing', edgeIds: [] };
|
|
399
|
+
}
|
|
388
400
|
if (
|
|
389
401
|
!reconciliation?.activeRevision
|
|
390
402
|
|| reconciliation.activeRevision.generation !== reconciliation.desiredGeneration
|
|
391
403
|
) {
|
|
392
404
|
return { ready: false, reason: 'no current validated active DNS revision', edgeIds: [] };
|
|
393
405
|
}
|
|
406
|
+
const slots = reconciliation.activeRevision.mxSlots;
|
|
407
|
+
if (
|
|
408
|
+
slots.length !== 2
|
|
409
|
+
|| slots[0]?.priority !== 10
|
|
410
|
+
|| slots[1]?.priority !== 20
|
|
411
|
+
|| slots[0].edgeId === slots[1].edgeId
|
|
412
|
+
|| slots[0].hostname === slots[1].hostname
|
|
413
|
+
) {
|
|
414
|
+
return { ready: false, reason: 'validated DNS revision does not contain distinct priority 10/20 MX edges', edgeIds: [] };
|
|
415
|
+
}
|
|
416
|
+
if (!reconciliation.intents.every((intent) => intent.status === 'valid')) {
|
|
417
|
+
return { ready: false, reason: 'managed email DNS intents are not currently valid', edgeIds: [] };
|
|
418
|
+
}
|
|
394
419
|
const live = await this.dcRouter.mailEdgeEligibility.resolveActiveForDomain(doc);
|
|
395
|
-
if (live.edges.length
|
|
396
|
-
return { ready: false, reason: live.reason || '
|
|
420
|
+
if (live.edges.length !== 2) {
|
|
421
|
+
return { ready: false, reason: live.reason || 'both validated active MX edges must be live', edgeIds: [] };
|
|
397
422
|
}
|
|
398
423
|
return {
|
|
399
424
|
ready: true,
|
|
@@ -685,6 +710,13 @@ export class EmailDomainManager {
|
|
|
685
710
|
return { edgeFilter };
|
|
686
711
|
}
|
|
687
712
|
|
|
713
|
+
private normalizeRotationIntervalDays(value: number): number {
|
|
714
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
715
|
+
throw new Error('DKIM rotationIntervalDays must be a positive integer');
|
|
716
|
+
}
|
|
717
|
+
return value;
|
|
718
|
+
}
|
|
719
|
+
|
|
688
720
|
private extractDkimPublicKey(value: unknown): string | undefined {
|
|
689
721
|
if (typeof value !== 'string') return undefined;
|
|
690
722
|
return value.match(/(?:^|;)\s*p=([A-Za-z0-9+/=]+)(?:;|$)/)?.[1];
|