@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.
Files changed (41) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.js +4 -1
  4. package/dist_ts/dns/classes.dns-server-runtime.js +3 -3
  5. package/dist_ts/dns/manager.dns.d.ts +16 -0
  6. package/dist_ts/dns/manager.dns.js +193 -40
  7. package/dist_ts/dns/providers/cloudflare.provider.d.ts +5 -0
  8. package/dist_ts/dns/providers/cloudflare.provider.js +78 -23
  9. package/dist_ts/dns/providers/interfaces.d.ts +2 -0
  10. package/dist_ts/email/classes.email-domain.manager.d.ts +1 -0
  11. package/dist_ts/email/classes.email-domain.manager.js +36 -8
  12. package/dist_ts/email/classes.mail-dns-sync.d.ts +8 -0
  13. package/dist_ts/email/classes.mail-dns-sync.js +239 -55
  14. package/dist_ts/email/classes.mail-edge-eligibility.js +44 -17
  15. package/dist_ts/email/classes.mail-egress-coordinator.js +13 -3
  16. package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +1 -0
  17. package/dist_ts/email/reconcile-mail-dns-singleton.js +6 -2
  18. package/dist_ts/opsserver/handlers/dns-provider.handler.js +3 -1
  19. package/dist_ts/opsserver/handlers/dns-record.handler.js +44 -35
  20. package/dist_ts/opsserver/handlers/domain.handler.js +13 -2
  21. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
  22. package/dist_ts_interfaces/data/email-domain.d.ts +2 -0
  23. package/dist_ts_web/00_commitinfo_data.js +1 -1
  24. package/package.json +4 -4
  25. package/readme.md +7 -7
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/classes.dcrouter.ts +4 -0
  28. package/ts/dns/classes.dns-server-runtime.ts +2 -2
  29. package/ts/dns/manager.dns.ts +235 -35
  30. package/ts/dns/providers/cloudflare.provider.ts +93 -26
  31. package/ts/dns/providers/interfaces.ts +3 -0
  32. package/ts/email/classes.email-domain.manager.ts +38 -6
  33. package/ts/email/classes.mail-dns-sync.ts +246 -53
  34. package/ts/email/classes.mail-edge-eligibility.ts +53 -15
  35. package/ts/email/classes.mail-egress-coordinator.ts +18 -4
  36. package/ts/email/reconcile-mail-dns-singleton.ts +8 -1
  37. package/ts/opsserver/handlers/dns-provider.handler.ts +2 -0
  38. package/ts/opsserver/handlers/dns-record.handler.ts +58 -49
  39. package/ts/opsserver/handlers/domain.handler.ts +11 -1
  40. package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
  41. 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 zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
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((r) => this.isSupportedType(r.type))
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 (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
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 (this.cfAccount as any).apiAccount.dns.records.edit(
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 (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
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+)\s+(.+)$/);
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
- return { content: match[2], priority };
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: any): IProviderRecord {
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 (record.type === 'MX') {
147
- value = `${Number(record.priority)} ${record.content}`;
148
- } else if (record.type === 'TXT') {
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: String(record.id),
161
- name: String(record.name),
162
- type: record.type as TDnsRecordType,
212
+ providerRecordId,
213
+ name,
214
+ type: type as TDnsRecordType,
163
215
  value,
164
- ttl: Number(record.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: opts.rotationIntervalDays ?? 90,
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) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
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, true), provisioned };
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, true),
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 === 0) {
396
- return { ready: false, reason: live.reason || 'no validated active egress edge is live', edgeIds: [] };
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];