feat(dns): add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local

This commit is contained in:
2026-04-08 14:54:49 +00:00
parent 5689e93665
commit 93cc5c7b06
21 changed files with 245 additions and 91 deletions

View File

@@ -25,9 +25,9 @@ import type {
* Responsibilities:
* - Load Domain/DnsRecord docs from the DB on start
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
* - Register manual-domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (manual domains hit smartdns,
* provider domains hit the provider API)
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
* smartdns, provider domains hit the provider API)
* - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy()
*
* Provider-managed domains are NEVER served from the embedded DnsServer — the
@@ -69,12 +69,12 @@ export class DnsManager {
/**
* Wire the embedded DnsServer instance after it has been created by
* DcRouter.setupDnsWithSocketHandler(). After this, manual records loaded
* from the DB are registered with the server.
* DcRouter.setupDnsWithSocketHandler(). After this, local records on
* dcrouter-hosted domains loaded from the DB are registered with the server.
*/
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
this.dnsServer = dnsServer;
await this.applyManualDomainsToDnsServer();
await this.applyDcrouterDomainsToDnsServer();
}
// ==========================================================================
@@ -83,7 +83,8 @@ export class DnsManager {
/**
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
* seed them as `source: 'manual'` records. On subsequent boots (DB has
* seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with
* local (`record.source: 'local'`) records. On subsequent boots (DB has
* entries), constructor config is ignored with a warning.
*/
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
@@ -117,7 +118,7 @@ export class DnsManager {
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = scope.toLowerCase();
domain.source = 'manual';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
@@ -144,7 +145,7 @@ export class DnsManager {
record.type = rec.type as TDnsRecordType;
record.value = rec.value;
record.ttl = rec.ttl ?? 300;
record.source = 'manual';
record.source = 'local';
record.createdAt = now;
record.updatedAt = now;
record.createdBy = 'seed';
@@ -174,28 +175,31 @@ export class DnsManager {
}
// ==========================================================================
// Manual-domain DnsServer wiring
// DcRouter-hosted domain DnsServer wiring
// ==========================================================================
/**
* Register all manual-domain records from the DB with the embedded DnsServer.
* Called once after attachDnsServer().
* Register all records from dcrouter-hosted domains in the DB with the
* embedded DnsServer. Called once after attachDnsServer().
*/
private async applyManualDomainsToDnsServer(): Promise<void> {
private async applyDcrouterDomainsToDnsServer(): Promise<void> {
if (!this.dnsServer) {
return;
}
const allDomains = await DomainDoc.findAll();
const manualDomains = allDomains.filter((d) => d.source === 'manual');
const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter');
let registered = 0;
for (const domain of manualDomains) {
for (const domain of dcrouterDomains) {
const records = await DnsRecordDoc.findByDomainId(domain.id);
for (const rec of records) {
this.registerRecordWithDnsServer(rec);
registered++;
}
}
logger.log('info', `DnsManager: registered ${registered} manual DNS record(s) from DB`);
logger.log(
'info',
`DnsManager: registered ${registered} dcrouter-hosted DNS record(s) from DB`,
);
}
/**
@@ -381,6 +385,12 @@ export class DnsManager {
credentials: TDnsProviderCredentials;
createdBy: string;
}): Promise<string> {
if (args.type === 'dcrouter') {
throw new Error(
'createProvider: cannot create a DnsProviderDoc with type "dcrouter" — ' +
'that type is reserved for the built-in pseudo-provider surfaced at read time.',
);
}
const now = Date.now();
const doc = new DnsProviderDoc();
doc.id = plugins.uuid.v4();
@@ -473,10 +483,10 @@ export class DnsManager {
}
/**
* Create a manual (authoritative) domain. dcrouter will serve DNS records
* for this domain via the embedded smartdns.DnsServer.
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
* DNS records for this domain via the embedded smartdns.DnsServer.
*/
public async createManualDomain(args: {
public async createDcrouterDomain(args: {
name: string;
description?: string;
createdBy: string;
@@ -485,7 +495,7 @@ export class DnsManager {
const doc = new DomainDoc();
doc.id = plugins.uuid.v4();
doc.name = args.name.toLowerCase();
doc.source = 'manual';
doc.source = 'dcrouter';
doc.authoritative = true;
doc.description = args.description;
doc.createdAt = now;
@@ -571,10 +581,11 @@ export class DnsManager {
/**
* Delete a domain and all of its DNS records. For provider domains, only
* removes the local mirror — does NOT touch the provider.
* For manual domains, also unregisters records from the embedded DnsServer.
* For dcrouter-hosted domains, also unregisters records from the embedded
* DnsServer.
*
* Note: smartdns has no public unregister-by-name API in the version pinned
* here, so manual record deletes only take effect after a restart. The DB
* here, so local record deletes only take effect after a restart. The DB
* is the source of truth and the next start will not register the deleted
* record.
*/
@@ -652,7 +663,7 @@ export class DnsManager {
doc.value = args.value;
doc.ttl = args.ttl ?? 300;
if (args.proxied !== undefined) doc.proxied = args.proxied;
doc.source = 'manual';
doc.source = 'local';
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
@@ -678,7 +689,7 @@ export class DnsManager {
return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
}
} else {
// Manual / authoritative — register with embedded DnsServer immediately
// dcrouter-hosted / authoritative — register with embedded DnsServer immediately
this.registerRecordWithDnsServer(doc);
}
@@ -722,7 +733,7 @@ export class DnsManager {
return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
}
} else {
// Re-register the manual record so the new closure picks up the updated fields
// Re-register the local record so the new closure picks up the updated fields
this.registerRecordWithDnsServer(doc);
}
@@ -748,7 +759,7 @@ export class DnsManager {
}
}
}
// For manual records: smartdns has no unregister API in the pinned version,
// For local records: smartdns has no unregister API in the pinned version,
// so the record stays served until the next restart. The DB delete still
// takes effect — on restart, the record will not be re-registered.
@@ -807,7 +818,7 @@ export class DnsManager {
public toPublicDomain(doc: DomainDoc): {
id: string;
name: string;
source: 'manual' | 'provider';
source: 'dcrouter' | 'provider';
providerId?: string;
authoritative: boolean;
nameservers?: string[];

View File

@@ -38,6 +38,17 @@ export function createDnsProvider(
}
return new CloudflareDnsProvider(credentials.apiToken);
}
case 'dcrouter': {
// The built-in DcRouter pseudo-provider has no runtime client — dcrouter
// itself serves the records via the embedded smartdns.DnsServer. This
// case exists only to satisfy the exhaustive switch; it should never
// actually run because the handler layer rejects any CRUD that would
// result in a DnsProviderDoc with type: 'dcrouter'.
throw new Error(
`createDnsProvider: 'dcrouter' is a built-in pseudo-provider — no runtime client exists. ` +
`This call indicates a DnsProviderDoc with type: 'dcrouter' was persisted, which should never happen.`,
);
}
default: {
// If you see a TypeScript error here after extending TDnsProviderType,
// add a `case` for the new type above. The `never` enforces exhaustiveness.