@serve.zone/dcrouter 18.7.1 → 18.8.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.
@@ -6,6 +6,7 @@ import {
6
6
  DnsRecordDoc,
7
7
  EmailDomainDoc,
8
8
  } from '../db/documents/index.js';
9
+ import { DcRouterDb } from '../db/classes.dcrouter-db.js';
9
10
  import type { IDcRouterOptions } from '../classes.dcrouter.js';
10
11
  import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js';
11
12
  import { createDnsProvider } from './providers/factory.js';
@@ -42,6 +43,10 @@ export interface IDnsRuntimeRegistration {
42
43
  origin: TDnsRuntimeRegistrationOrigin;
43
44
  }
44
45
 
46
+ interface IDnsRuntimeRegistrationState extends IDnsRuntimeRegistration {
47
+ registrations: plugins.smartdns.dnsServerMod.IDnsHandlerRegistration[];
48
+ }
49
+
45
50
  export interface IDnsRecordDeleteResult {
46
51
  id: string;
47
52
  success: boolean;
@@ -80,21 +85,18 @@ export class DnsManager {
80
85
  * keyed by DomainDoc.id then by `name|type`.
81
86
  *
82
87
  * Teardown must remove exactly what registration added — no more, no less.
83
- * smartdns' `unregisterHandler(pattern, types)` removes *every* handler for a
84
- * pattern/type pair, including the nameserver glue handlers owned by
85
- * DnsServerRuntime and the global wildcard A handler. Reconstructing "would I
86
- * have registered this?" at delete time is therefore unsafe: any drift in the
87
- * inputs (authority changed, ownership changed, a record row vanished) makes the
88
- * reconstruction tear down another owner's handlers or miss its own. Recording
89
- * the actual registrations, with their origin, removes that class of bug.
88
+ * Every key therefore retains all SmartDNS registration handles for the RRset;
89
+ * the coarse `unregisterHandler(pattern, types)` API is never used. Recording
90
+ * the actual registrations, with their origin, prevents deletion or authority
91
+ * reconciliation from removing another owner's identical handler.
90
92
  */
91
- private runtimeRegistrations = new Map<string, Map<string, IDnsRuntimeRegistration>>();
93
+ private runtimeRegistrations = new Map<string, Map<string, IDnsRuntimeRegistrationState>>();
92
94
 
93
95
  /** Serializes managed-mail reconciliation with destructive domain/provider deletion. */
94
96
  private managedMailDnsMutationChain: Promise<void> = Promise.resolve();
95
97
  private acceptsManagedMailDnsMutations = true;
96
98
 
97
- /** Supplies the effective authority set (bootstrap ∪ delegation-verified). */
99
+ /** Supplies the delegation-verified authority set. */
98
100
  private authorityZonesResolver?: () => string[];
99
101
 
100
102
  constructor(private options: IDcRouterOptions) {}
@@ -122,31 +124,60 @@ export class DnsManager {
122
124
  name: string,
123
125
  type: TDnsRecordType,
124
126
  origin: TDnsRuntimeRegistrationOrigin,
127
+ registration: plugins.smartdns.dnsServerMod.IDnsHandlerRegistration,
125
128
  ): void {
126
129
  let owned = this.runtimeRegistrations.get(domainId);
127
130
  if (!owned) {
128
- owned = new Map<string, IDnsRuntimeRegistration>();
131
+ owned = new Map<string, IDnsRuntimeRegistrationState>();
129
132
  this.runtimeRegistrations.set(domainId, owned);
130
133
  }
131
- owned.set(this.rrsetKey(name, type), { name: name.toLowerCase(), type, origin });
134
+ const key = this.rrsetKey(name, type);
135
+ const existing = owned.get(key);
136
+ if (existing) {
137
+ if (existing.origin !== origin) {
138
+ registration.unregister();
139
+ throw new Error(
140
+ `DnsManager: cannot mix ${existing.origin} and ${origin} runtime registrations for ${name} ${type}`,
141
+ );
142
+ }
143
+ existing.registrations.push(registration);
144
+ return;
145
+ }
146
+ owned.set(key, {
147
+ name: name.toLowerCase(),
148
+ type,
149
+ origin,
150
+ registrations: [registration],
151
+ });
132
152
  }
133
153
 
134
- private untrackRuntimeRegistration(
154
+ private unregisterRuntimeRegistration(
135
155
  domainId: string,
136
156
  name: string,
137
157
  type: TDnsRecordType,
138
- ): void {
158
+ ): number {
139
159
  const owned = this.runtimeRegistrations.get(domainId);
140
- if (!owned) return;
160
+ if (!owned) return 0;
161
+ const registration = owned.get(this.rrsetKey(name, type));
162
+ if (!registration) return 0;
141
163
  owned.delete(this.rrsetKey(name, type));
142
164
  if (owned.size === 0) {
143
165
  this.runtimeRegistrations.delete(domainId);
144
166
  }
167
+ let removed = 0;
168
+ for (const handlerRegistration of registration.registrations) {
169
+ if (handlerRegistration.unregister()) removed++;
170
+ }
171
+ return removed;
145
172
  }
146
173
 
147
174
  /** Registrations currently owned by a domain. Exposed for teardown assertions. */
148
175
  public listRuntimeRegistrations(domainId: string): IDnsRuntimeRegistration[] {
149
- return [...(this.runtimeRegistrations.get(domainId)?.values() || [])];
176
+ return [...(this.runtimeRegistrations.get(domainId)?.values() || [])].map((registration) => ({
177
+ name: registration.name,
178
+ type: registration.type,
179
+ origin: registration.origin,
180
+ }));
150
181
  }
151
182
 
152
183
  /**
@@ -163,19 +194,22 @@ export class DnsManager {
163
194
  private tearDownDomainRuntimeRegistrations(domainId: string): void {
164
195
  const owned = this.runtimeRegistrations.get(domainId);
165
196
  this.runtimeRegistrations.delete(domainId);
166
- if (!owned || !this.dnsServer) {
197
+ if (!owned) {
167
198
  return;
168
199
  }
169
200
  const generatedZones: string[] = [];
201
+ let removedHandlers = 0;
170
202
  for (const registration of owned.values()) {
171
- this.dnsServer.unregisterHandler(registration.name, [registration.type]);
203
+ for (const handlerRegistration of registration.registrations) {
204
+ if (handlerRegistration.unregister()) removedHandlers++;
205
+ }
172
206
  if (registration.origin === 'generated-default') {
173
207
  generatedZones.push(`${registration.name} ${registration.type}`);
174
208
  }
175
209
  }
176
210
  logger.log(
177
211
  'info',
178
- `DnsManager: unregistered ${owned.size} runtime DNS handler key(s) for domain ${domainId}`
212
+ `DnsManager: unregistered ${removedHandlers} runtime DNS handler(s) across ${owned.size} key(s) for domain ${domainId}`
179
213
  + (generatedZones.length ? `, including generated default(s) ${generatedZones.join(', ')}` : ''),
180
214
  { zone: 'dns', domainId },
181
215
  );
@@ -194,8 +228,7 @@ export class DnsManager {
194
228
  this.acceptsManagedMailDnsMutations = false;
195
229
  await this.managedMailDnsMutationChain;
196
230
  this.providerClients.clear();
197
- this.runtimeRegistrations.clear();
198
- this.dnsServer = undefined;
231
+ this.detachDnsServer();
199
232
  }
200
233
 
201
234
  /**
@@ -204,8 +237,26 @@ export class DnsManager {
204
237
  * dcrouter-hosted domains loaded from the DB are registered with the server.
205
238
  */
206
239
  public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
240
+ if (this.dnsServer === dnsServer) return;
241
+ this.detachDnsServer();
207
242
  this.dnsServer = dnsServer;
208
- await this.applyDcrouterDomainsToDnsServer();
243
+ try {
244
+ await this.applyDcrouterDomainsToDnsServer();
245
+ } catch (error) {
246
+ this.detachDnsServer(dnsServer);
247
+ throw error;
248
+ }
249
+ }
250
+
251
+ /** Remove manager-owned handlers from the current server before replacement or shutdown. */
252
+ public detachDnsServer(expectedServerArg?: plugins.smartdns.dnsServerMod.DnsServer): boolean {
253
+ if (expectedServerArg && this.dnsServer !== expectedServerArg) return false;
254
+ for (const domainId of [...this.runtimeRegistrations.keys()]) {
255
+ this.tearDownDomainRuntimeRegistrations(domainId);
256
+ }
257
+ this.runtimeRegistrations.clear();
258
+ this.dnsServer = undefined;
259
+ return true;
209
260
  }
210
261
 
211
262
  // ==========================================================================
@@ -254,10 +305,9 @@ export class DnsManager {
254
305
  * bootstrap `dnsScopes` zones, which could neither appear for a zone verified
255
306
  * after startup nor disappear for one whose authority was revoked.
256
307
  *
257
- * Ownership is verified first. smartdns marks any answer a registered handler
258
- * produces as authoritative (`aa`) regardless of `authoritativeZones`, so
259
- * registering this handler for a zone we cannot prove we own makes dcrouter
260
- * publicly claim authority over somebody else's domain.
308
+ * Ownership is verified first. SmartDNS suppresses default-authority handlers
309
+ * outside the live authority set, and a zone only enters that set after its
310
+ * delegation is proven, so registration and authority use the same evidence.
261
311
  */
262
312
  private async registerAuthoritativeZoneDefaults(
263
313
  domain: DomainDoc,
@@ -288,7 +338,7 @@ export class DnsManager {
288
338
  logger.log(
289
339
  'error',
290
340
  `DnsManager: refusing to serve authoritative apex NS records for ${zoneName} — ${ownership.detail}. `
291
- + 'dcrouter answers with the aa flag for every handler it registers, so this zone stays unserved (REFUSED) until ownership is verified.',
341
+ + 'SmartDNS suppresses authoritative handlers outside the verified authority set, so this zone stays unserved (REFUSED) until ownership is verified.',
292
342
  { zone: 'dns', domainId: domain.id, ownershipReason: ownership.reason },
293
343
  );
294
344
  return;
@@ -302,7 +352,7 @@ export class DnsManager {
302
352
  return;
303
353
  }
304
354
  for (const nsDomain of nsDomains) {
305
- this.dnsServer.registerHandler(zoneName, ['NS'], (question) => {
355
+ const registration = this.dnsServer.registerHandler(zoneName, ['NS'], (question) => {
306
356
  if (question.name.toLowerCase() === zoneName && question.type.toUpperCase() === 'NS') {
307
357
  return {
308
358
  name: question.name,
@@ -313,9 +363,15 @@ export class DnsManager {
313
363
  };
314
364
  }
315
365
  return null;
316
- });
366
+ }, { owner: `dcrouter:dns-manager:${domain.id}` });
367
+ this.trackRuntimeRegistration(
368
+ domain.id,
369
+ zoneName,
370
+ 'NS',
371
+ 'generated-default',
372
+ registration,
373
+ );
317
374
  }
318
- this.trackRuntimeRegistration(domain.id, zoneName, 'NS', 'generated-default');
319
375
  logger.log(
320
376
  'info',
321
377
  `DnsManager: serving default apex NS records for authoritative zone ${zoneName} `
@@ -389,8 +445,7 @@ export class DnsManager {
389
445
 
390
446
  if (!ownership.verified && hasGenerated) {
391
447
  // Authority lost: stop answering for it immediately.
392
- this.untrackRuntimeRegistration(domain.id, zoneName, 'NS');
393
- this.dnsServer.unregisterHandler(zoneName, ['NS']);
448
+ this.unregisterRuntimeRegistration(domain.id, zoneName, 'NS');
394
449
  unregistered.push(zoneName);
395
450
  continue;
396
451
  }
@@ -480,7 +535,7 @@ export class DnsManager {
480
535
  */
481
536
  private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
482
537
  if (!this.dnsServer) return;
483
- this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
538
+ const registration = this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
484
539
  if (question.name.toLowerCase() === rec.name.toLowerCase() && question.type.toUpperCase() === rec.type) {
485
540
  return {
486
541
  name: question.name,
@@ -491,10 +546,9 @@ export class DnsManager {
491
546
  };
492
547
  }
493
548
  return null;
494
- });
495
- // Tracked here rather than at each call site so the direct registration in
496
- // migrateToDcrouter() is covered as well.
497
- this.trackRuntimeRegistration(rec.domainId, rec.name, rec.type, 'persisted');
549
+ }, { owner: `dcrouter:dns-manager:${rec.domainId}` });
550
+ // Track beside registration so every call site retains the exact handle.
551
+ this.trackRuntimeRegistration(rec.domainId, rec.name, rec.type, 'persisted', registration);
498
552
  }
499
553
 
500
554
  private rrsetKey(name: string, type: TDnsRecordType): string {
@@ -514,8 +568,7 @@ export class DnsManager {
514
568
  type: TDnsRecordType,
515
569
  ): Promise<void> {
516
570
  if (!this.dnsServer) return;
517
- this.dnsServer.unregisterHandler(name, [type]);
518
- this.untrackRuntimeRegistration(domainId, name, type);
571
+ this.unregisterRuntimeRegistration(domainId, name, type);
519
572
  const records = await DnsRecordDoc.findByDomainId(domainId);
520
573
  for (const record of records) {
521
574
  if (record.name.toLowerCase() === name.toLowerCase() && record.type === type) {
@@ -1199,8 +1252,8 @@ export class DnsManager {
1199
1252
  return { success: false, message: 'Domain is already in the target configuration' };
1200
1253
  }
1201
1254
 
1202
- const records = await DnsRecordDoc.findByDomainId(domain.id);
1203
1255
  if (args.targetSource === 'provider') {
1256
+ const records = await DnsRecordDoc.findByDomainId(domain.id);
1204
1257
  return await this.migrateToDnsProvider(
1205
1258
  domain,
1206
1259
  records,
@@ -1208,7 +1261,7 @@ export class DnsManager {
1208
1261
  args.deleteExistingProviderRecords ?? false,
1209
1262
  );
1210
1263
  }
1211
- return await this.migrateToDcrouter(domain, records);
1264
+ return await this.migrateToDcrouter(domain.id);
1212
1265
  });
1213
1266
  }
1214
1267
 
@@ -1298,36 +1351,76 @@ export class DnsManager {
1298
1351
  * Migrate domain from provider-managed to dcrouter-hosted (authoritative).
1299
1352
  */
1300
1353
  private async migrateToDcrouter(
1301
- domain: DomainDoc,
1302
- records: DnsRecordDoc[],
1354
+ domainId: string,
1303
1355
  ): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
1304
- // Register each record with the embedded DnsServer
1305
- let migrated = 0;
1306
- for (const rec of records) {
1307
- try {
1308
- this.registerRecordWithDnsServer(rec);
1309
- // Update the record doc to local
1310
- rec.source = 'local' as TDnsRecordSource;
1311
- rec.providerRecordId = undefined;
1312
- await rec.save();
1313
- migrated++;
1314
- } catch (err: unknown) {
1315
- logger.log('warn', `Failed to register record ${rec.name} ${rec.type} with DnsServer: ${(err as Error).message}`);
1316
- }
1356
+ const db = DcRouterDb.getInstance().getDb();
1357
+ const session = db.startSession();
1358
+ let migration: {
1359
+ domain: DomainDoc;
1360
+ records: DnsRecordDoc[];
1361
+ ownership: TDomainOwnership;
1362
+ } | undefined;
1363
+ try {
1364
+ migration = await session.withTransaction(async () => {
1365
+ const domain = await DomainDoc.getInstance({ id: domainId }, { session });
1366
+ if (!domain) {
1367
+ throw new Error(`Domain ${domainId} disappeared during provider-to-dcrouter migration`);
1368
+ }
1369
+ const records = await DnsRecordDoc.getInstances({ domainId }, { session });
1370
+ const ownership = await this.resolveOwnershipForPendingDcrouterZone(domain.name);
1371
+ const now = Date.now();
1372
+
1373
+ for (const rec of records) {
1374
+ rec.source = 'local' as TDnsRecordSource;
1375
+ rec.providerRecordId = undefined;
1376
+ rec.updatedAt = now;
1377
+ await rec.save({ session });
1378
+ await db.mongoDb.collection('DnsRecordDoc').updateOne(
1379
+ { id: rec.id },
1380
+ { $unset: { providerRecordId: '' } },
1381
+ { session },
1382
+ );
1383
+ delete (rec as any).providerRecordId;
1384
+ }
1385
+
1386
+ // Losing the provider link means the credentialed zone listing no
1387
+ // longer proves ownership, so authority is re-derived rather than
1388
+ // assumed.
1389
+ domain.source = 'dcrouter';
1390
+ domain.providerId = undefined;
1391
+ domain.externalZoneId = undefined;
1392
+ domain.nameservers = undefined;
1393
+ domain.lastSyncedAt = undefined;
1394
+ domain.updatedAt = now;
1395
+ domain.authoritative = ownership.verified;
1396
+ await domain.save({ session });
1397
+ await db.mongoDb.collection('DomainDoc').updateOne(
1398
+ { id: domain.id },
1399
+ {
1400
+ $unset: {
1401
+ providerId: '',
1402
+ externalZoneId: '',
1403
+ nameservers: '',
1404
+ lastSyncedAt: '',
1405
+ },
1406
+ },
1407
+ { session },
1408
+ );
1409
+ delete (domain as any).providerId;
1410
+ delete (domain as any).externalZoneId;
1411
+ delete (domain as any).nameservers;
1412
+ delete (domain as any).lastSyncedAt;
1413
+
1414
+ return { domain, records, ownership };
1415
+ });
1416
+ } finally {
1417
+ await session.endSession();
1317
1418
  }
1318
1419
 
1319
- // Update domain metadata. Losing the provider link means the credentialed
1320
- // zone listing no longer proves ownership, so authority is re-derived rather
1321
- // than assumed.
1322
- domain.source = 'dcrouter';
1323
- domain.providerId = undefined;
1324
- domain.externalZoneId = undefined;
1325
- domain.nameservers = undefined;
1326
- domain.lastSyncedAt = undefined;
1327
- domain.updatedAt = Date.now();
1328
- const ownership = await this.resolveOwnershipForPendingDcrouterZone(domain.name);
1329
- domain.authoritative = ownership.verified;
1330
- await domain.save();
1420
+ if (!migration) {
1421
+ throw new Error(`Provider-to-dcrouter migration for ${domainId} committed without a result`);
1422
+ }
1423
+ const { domain, records, ownership } = migration;
1331
1424
  if (!ownership.verified) {
1332
1425
  logger.log(
1333
1426
  'error',
@@ -1335,10 +1428,26 @@ export class DnsManager {
1335
1428
  { zone: 'dns', domainId: domain.id, ownershipReason: ownership.reason },
1336
1429
  );
1337
1430
  }
1338
- await this.registerAuthoritativeZoneDefaults(domain);
1339
1431
 
1340
- logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`);
1341
- return { success: true, recordsMigrated: migrated };
1432
+ // Runtime state is rebuilt only after every durable row has committed. A
1433
+ // failed transaction therefore leaves the provider-backed runtime untouched.
1434
+ this.tearDownDomainRuntimeRegistrations(domain.id);
1435
+ try {
1436
+ const rrsets = new Set(records.map((recordArg) => (
1437
+ this.rrsetKey(recordArg.name, recordArg.type)
1438
+ )));
1439
+ for (const rrset of rrsets) {
1440
+ const [name, type] = rrset.split('|') as [string, TDnsRecordType];
1441
+ await this.refreshLocalRrset(domain.id, name, type);
1442
+ }
1443
+ await this.registerAuthoritativeZoneDefaults(domain);
1444
+ } catch (error) {
1445
+ this.tearDownDomainRuntimeRegistrations(domain.id);
1446
+ throw error;
1447
+ }
1448
+
1449
+ logger.log('info', `Domain ${domain.name} migrated to dcrouter (${records.length} records)`);
1450
+ return { success: true, recordsMigrated: records.length };
1342
1451
  }
1343
1452
 
1344
1453
  // ==========================================================================
@@ -1622,9 +1731,7 @@ export class DnsManager {
1622
1731
  * Unregister a record's handler from the embedded DnsServer.
1623
1732
  */
1624
1733
  public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void {
1625
- this.untrackRuntimeRegistration(rec.domainId, rec.name, rec.type);
1626
- if (!this.dnsServer) return;
1627
- this.dnsServer.unregisterHandler(rec.name, [rec.type]);
1734
+ this.unregisterRuntimeRegistration(rec.domainId, rec.name, rec.type);
1628
1735
  }
1629
1736
 
1630
1737
  // ==========================================================================
@@ -144,40 +144,6 @@ export function evaluateMailMxValues(
144
144
  return { valid: true, outcome: 'valid' };
145
145
  }
146
146
 
147
- function expandIpv6(address: string): string[] | undefined {
148
- let normalized = address.toLowerCase();
149
- if (normalized.includes('.')) {
150
- const lastColon = normalized.lastIndexOf(':');
151
- const ipv4 = normalized.slice(lastColon + 1).split('.').map(Number);
152
- if (ipv4.length !== 4 || ipv4.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
153
- return undefined;
154
- }
155
- normalized = `${normalized.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
156
- }
157
- const halves = normalized.split('::');
158
- if (halves.length > 2) return undefined;
159
- const left = halves[0] ? halves[0].split(':') : [];
160
- const right = halves[1] ? halves[1].split(':') : [];
161
- const missing = 8 - left.length - right.length;
162
- if ((halves.length === 1 && missing !== 0) || missing < 0) return undefined;
163
- const groups = [...left, ...Array.from({ length: missing }, () => '0'), ...right];
164
- if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return undefined;
165
- return groups.map((group) => group.padStart(4, '0'));
166
- }
167
-
168
- export function toReverseDnsName(address: string): string {
169
- const family = plugins.smartnetwork.getIpVersion(address);
170
- if (family === 4) {
171
- return `${address.split('.').reverse().join('.')}.in-addr.arpa`;
172
- }
173
- if (family === 6) {
174
- const groups = expandIpv6(address);
175
- if (!groups) throw new Error(`Invalid IPv6 address: ${address}`);
176
- return `${groups.join('').split('').reverse().join('.')}.ip6.arpa`;
177
- }
178
- throw new Error(`Invalid IP address: ${address}`);
179
- }
180
-
181
147
  export class SmartDnsMailVerifier implements IMailDnsVerifier {
182
148
  private client?: plugins.smartdns.dnsClientMod.Smartdns;
183
149
 
@@ -316,7 +282,15 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
316
282
  if (identity.ipv6) checks.push({ family: 'AAAA', address: identity.ipv6 });
317
283
  try {
318
284
  for (const check of checks) {
319
- const ptrQuery = await this.queryValues(toReverseDnsName(check.address), 'PTR');
285
+ const reverseDnsName = plugins.smartnetwork.toReverseDnsName(check.address);
286
+ if (!reverseDnsName) {
287
+ return {
288
+ valid: false,
289
+ outcome: 'invalid',
290
+ reason: `Invalid source-bound IP address: ${check.address}`,
291
+ };
292
+ }
293
+ const ptrQuery = await this.queryValues(reverseDnsName, 'PTR');
320
294
  if (ptrQuery.status === 'error') {
321
295
  return { valid: false, outcome: 'lookup-error', reason: `PTR lookup failed: ${ptrQuery.reason}` };
322
296
  }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.7.1',
6
+ version: '18.8.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }