diff --git a/changelog.md b/changelog.md index fdb4022..19bb2ce 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-08 - 13.9.0 - feat(dns) +add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local + +- Expose a synthetic built-in "DcRouter" provider in provider listings and block create, edit, delete, test, and external domain listing operations for it +- Rename domain and record source semantics from "manual" to "dcrouter" and "local" across backend, interfaces, and UI +- Add database migrations to convert existing DomainDoc.source and DnsRecordDoc.source values to the new naming +- Update domain creation flows and provider UI labels to reflect dcrouter-hosted authoritative domains + ## 2026-04-08 - 13.8.0 - feat(acme) add DB-backed ACME configuration management and OpsServer certificate settings UI diff --git a/package.json b/package.json index d03aeda..5cc280a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@api.global/typedserver": "^8.4.6", "@api.global/typedsocket": "^4.1.2", "@apiclient.xyz/cloudflare": "^7.1.0", - "@design.estate/dees-catalog": "^3.69.1", + "@design.estate/dees-catalog": "^3.70.0", "@design.estate/dees-element": "^2.2.4", "@push.rocks/lik": "^6.4.0", "@push.rocks/projectinfo": "^5.1.0", @@ -49,7 +49,7 @@ "@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartlog": "^3.2.2", "@push.rocks/smartmetrics": "^3.0.3", - "@push.rocks/smartmigration": "1.1.1", + "@push.rocks/smartmigration": "1.2.0", "@push.rocks/smartmta": "^5.3.1", "@push.rocks/smartnetwork": "^4.5.2", "@push.rocks/smartpath": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15a7775..9be375e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^7.1.0 version: 7.1.0 '@design.estate/dees-catalog': - specifier: ^3.69.1 - version: 3.69.1(@tiptap/pm@2.27.2) + specifier: ^3.70.0 + version: 3.70.0(@tiptap/pm@2.27.2) '@design.estate/dees-element': specifier: ^2.2.4 version: 2.2.4 @@ -66,8 +66,8 @@ importers: specifier: ^3.0.3 version: 3.0.3 '@push.rocks/smartmigration': - specifier: 1.1.1 - version: 1.1.1(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7)) + specifier: 1.2.0 + version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7)) '@push.rocks/smartmta': specifier: ^5.3.1 version: 5.3.1 @@ -353,8 +353,8 @@ packages: '@configvault.io/interfaces@1.0.17': resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} - '@design.estate/dees-catalog@3.69.1': - resolution: {integrity: sha512-OSpHB/hfOrL2mkAfF50TqTKJ2hvPd7Cj1WklAmFckyjloE4fd7DRDeXdI/Bziq9152gExipX5VoofTAOr4rF5w==} + '@design.estate/dees-catalog@3.70.0': + resolution: {integrity: sha512-bNqOxxl83FNCCV+7QoUj6oeRC0VTExWOClrLrHNMoLIU0TCtzhcmQqiuJhdWrcCwZ5RBhXHGMSFsR62d2RcWpw==} '@design.estate/dees-comms@1.0.30': resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} @@ -1231,8 +1231,8 @@ packages: '@push.rocks/smartmetrics@3.0.3': resolution: {integrity: sha512-RYY4NOla3kraZYVF9TBHgIz4/hSkqVDVNP7tLwhLK5mGBPBy8I/9NWXX6txZKQw6QihP85YD8mWUuUu2xS4D6Q==} - '@push.rocks/smartmigration@1.1.1': - resolution: {integrity: sha512-K/eLN9cNy+CLOT73rhI93vOy/vGwpV46iJpjRUyPwHsMcQcV6po2idk5+XZQzeuq2x7KpKuUPtZ6gXMtf5Y/ig==} + '@push.rocks/smartmigration@1.2.0': + resolution: {integrity: sha512-H2diE1UbZm4cXjxgxkt2YQW3aUQ3QVVU/e8Ws30hzIep0xIqL1BH6//WawA5ZBQhnAOBssZpVOuWOd3GIeBq+Q==} peerDependencies: '@push.rocks/smartbucket': ^4.6.0 '@push.rocks/smartdata': ^7.1.7 @@ -4315,7 +4315,7 @@ snapshots: '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3) '@cloudflare/workers-types': 4.20260405.1 - '@design.estate/dees-catalog': 3.69.1(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.70.0(@tiptap/pm@2.27.2) '@design.estate/dees-comms': 1.0.30 '@push.rocks/lik': 6.4.0 '@push.rocks/smartdelay': 3.0.5 @@ -4844,7 +4844,7 @@ snapshots: dependencies: '@api.global/typedrequest-interfaces': 3.0.19 - '@design.estate/dees-catalog@3.69.1(@tiptap/pm@2.27.2)': + '@design.estate/dees-catalog@3.70.0(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-element': 2.2.4 @@ -6354,7 +6354,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartlog': 3.2.2 - '@push.rocks/smartmigration@1.1.1(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))': + '@push.rocks/smartmigration@1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))': dependencies: '@push.rocks/smartlog': 3.2.2 '@push.rocks/smartversion': 3.1.0 @@ -6900,7 +6900,7 @@ snapshots: '@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)': dependencies: - '@design.estate/dees-catalog': 3.69.1(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.70.0(@tiptap/pm@2.27.2) '@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-element': 2.2.4 '@design.estate/dees-wcctools': 3.8.0 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 1568d6e..1c60ccc 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.8.0', + version: '13.9.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index a9b834c..ab915c4 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -1792,7 +1792,8 @@ export class DcRouter { logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`); } - // Hand the DnsServer to DnsManager so DB-backed manual records get registered too. + // Hand the DnsServer to DnsManager so DB-backed local records on + // dcrouter-hosted domains get registered too. if (this.dnsManager && this.dnsServer) { await this.dnsManager.attachDnsServer(this.dnsServer); } diff --git a/ts/dns/manager.dns.ts b/ts/dns/manager.dns.ts index c377e1f..10d3b4d 100644 --- a/ts/dns/manager.dns.ts +++ b/ts/dns/manager.dns.ts @@ -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 { 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 { @@ -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 { + private async applyDcrouterDomainsToDnsServer(): Promise { 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 { + 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[]; diff --git a/ts/dns/providers/factory.ts b/ts/dns/providers/factory.ts index 4102807..59d2e32 100644 --- a/ts/dns/providers/factory.ts +++ b/ts/dns/providers/factory.ts @@ -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. diff --git a/ts/opsserver/handlers/dns-provider.handler.ts b/ts/opsserver/handlers/dns-provider.handler.ts index f0ff5a0..280fcc4 100644 --- a/ts/opsserver/handlers/dns-provider.handler.ts +++ b/ts/opsserver/handlers/dns-provider.handler.ts @@ -46,15 +46,28 @@ export class DnsProviderHandler { } private registerHandlers(): void { - // Get all providers + // Get all providers — prepends the built-in DcRouter pseudo-provider + // so operators see a uniform "who serves this?" list that includes the + // authoritative dcrouter alongside external accounts. this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getDnsProviders', async (dataArg) => { await this.requireAuth(dataArg, 'dns-providers:read'); const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; - if (!dnsManager) return { providers: [] }; - return { providers: await dnsManager.listProviders() }; + const synthetic: interfaces.data.IDnsProviderPublic = { + id: interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID, + name: 'DcRouter', + type: 'dcrouter', + status: 'ok', + createdAt: 0, + updatedAt: 0, + createdBy: 'system', + hasCredentials: false, + builtIn: true, + }; + const real = dnsManager ? await dnsManager.listProviders() : []; + return { providers: [synthetic, ...real] }; }, ), ); @@ -78,6 +91,12 @@ export class DnsProviderHandler { 'createDnsProvider', async (dataArg) => { const userId = await this.requireAuth(dataArg, 'dns-providers:write'); + if (dataArg.type === 'dcrouter') { + return { + success: false, + message: 'cannot create built-in provider', + }; + } const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) { return { success: false, message: 'DnsManager not initialized (DB disabled?)' }; @@ -99,6 +118,9 @@ export class DnsProviderHandler { 'updateDnsProvider', async (dataArg) => { await this.requireAuth(dataArg, 'dns-providers:write'); + if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) { + return { success: false, message: 'cannot edit built-in provider' }; + } const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; const ok = await dnsManager.updateProvider(dataArg.id, { @@ -116,6 +138,9 @@ export class DnsProviderHandler { 'deleteDnsProvider', async (dataArg) => { await this.requireAuth(dataArg, 'dns-providers:write'); + if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) { + return { success: false, message: 'cannot delete built-in provider' }; + } const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false); @@ -129,6 +154,13 @@ export class DnsProviderHandler { 'testDnsProvider', async (dataArg) => { await this.requireAuth(dataArg, 'dns-providers:read'); + if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) { + return { + ok: false, + error: 'built-in provider has no external connection to test', + testedAt: Date.now(), + }; + } const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) { return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() }; @@ -144,6 +176,12 @@ export class DnsProviderHandler { 'listProviderDomains', async (dataArg) => { await this.requireAuth(dataArg, 'dns-providers:read'); + if (dataArg.providerId === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) { + return { + success: false, + message: 'built-in provider has no external domain listing — use "Add DcRouter Domain" instead', + }; + } const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; try { diff --git a/ts/opsserver/handlers/domain.handler.ts b/ts/opsserver/handlers/domain.handler.ts index a3f1e07..fa1956d 100644 --- a/ts/opsserver/handlers/domain.handler.ts +++ b/ts/opsserver/handlers/domain.handler.ts @@ -71,7 +71,7 @@ export class DomainHandler { ), ); - // Create manual domain + // Create dcrouter-hosted domain this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createDomain', @@ -80,7 +80,7 @@ export class DomainHandler { const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; try { - const id = await dnsManager.createManualDomain({ + const id = await dnsManager.createDcrouterDomain({ name: dataArg.name, description: dataArg.description, createdBy: userId, diff --git a/ts_interfaces/data/dns-provider.ts b/ts_interfaces/data/dns-provider.ts index a884c17..13414bd 100644 --- a/ts_interfaces/data/dns-provider.ts +++ b/ts_interfaces/data/dns-provider.ts @@ -1,9 +1,28 @@ /** - * Supported DNS provider types. Initially Cloudflare; the abstraction is - * designed so additional providers (Route53, Gandi, DigitalOcean…) can be - * added by implementing the IDnsProvider class interface in ts/dns/providers/. + * Stable ID for the built-in DcRouter pseudo-provider. The Providers list + * surfaces this as the first, non-deletable row so operators see a uniform + * "who serves this?" answer for every domain. The ID is magic — it never + * exists in the DnsProviderDoc collection; handlers inject it at read time + * and reject any mutation that targets it. */ -export type TDnsProviderType = 'cloudflare'; +export const DCROUTER_BUILTIN_PROVIDER_ID = '__dcrouter__'; + +/** + * Supported DNS provider types. + * + * - 'cloudflare' → Cloudflare account (API token-based). Provider stays + * authoritative; dcrouter pushes record changes via API. + * - 'dcrouter' → Built-in pseudo-provider for dcrouter-hosted zones. + * dcrouter itself is the authoritative DNS server. No + * credentials, cannot be created/edited/deleted through + * the provider CRUD — the Providers view renders it from + * a handler-level synthetic row. + * + * The abstraction is designed so additional providers (Route53, Gandi, + * DigitalOcean, foreign dcrouters…) can be added by implementing the + * IDnsProvider class interface in ts/dns/providers/. + */ +export type TDnsProviderType = 'cloudflare' | 'dcrouter'; /** * Status of the last connection test against a provider. @@ -58,6 +77,12 @@ export interface IDnsProviderPublic { createdBy: string; /** Whether credentials are configured (true after creation). Never the secret itself. */ hasCredentials: boolean; + /** + * True for the built-in DcRouter pseudo-provider — read-only, cannot be + * created / edited / deleted. Injected by the handler layer, never + * persisted in the DnsProviderDoc collection. + */ + builtIn?: boolean; } /** @@ -114,6 +139,13 @@ export interface IDnsProviderTypeDescriptor { * credentials each one needs. Used by both backend and frontend. */ export const dnsProviderTypeDescriptors: ReadonlyArray = [ + { + type: 'dcrouter', + displayName: 'DcRouter (built-in)', + description: + 'Built-in authoritative DNS. Records are served directly by dcrouter — delegate the domain\'s NS records to make this effective.', + credentialFields: [], + }, { type: 'cloudflare', displayName: 'Cloudflare', diff --git a/ts_interfaces/data/dns-record.ts b/ts_interfaces/data/dns-record.ts index 304cce3..8cf3154 100644 --- a/ts_interfaces/data/dns-record.ts +++ b/ts_interfaces/data/dns-record.ts @@ -6,16 +6,18 @@ export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA /** * Where a DNS record came from. * - * - 'manual' → created in the dcrouter UI / API - * - 'synced' → pulled from a provider during a sync operation + * - 'local' → originated in this dcrouter (created via UI / API) + * - 'synced' → pulled from an upstream provider (Cloudflare, foreign + * dcrouter, …) during a sync operation */ -export type TDnsRecordSource = 'manual' | 'synced'; +export type TDnsRecordSource = 'local' | 'synced'; /** - * A DNS record. For manual (authoritative) domains, the record is registered - * with the embedded smartdns.DnsServer. For provider-managed domains, the - * record is mirrored from / pushed to the provider API and `providerRecordId` - * holds the provider's internal record id (for updates and deletes). + * A DNS record. For dcrouter-hosted (authoritative) domains, the record is + * registered with the embedded smartdns.DnsServer. For provider-managed + * domains, the record is mirrored from / pushed to the provider API and + * `providerRecordId` holds the provider's internal record id (for updates + * and deletes). */ export interface IDnsRecord { id: string; diff --git a/ts_interfaces/data/domain.ts b/ts_interfaces/data/domain.ts index 927c582..4f5d265 100644 --- a/ts_interfaces/data/domain.ts +++ b/ts_interfaces/data/domain.ts @@ -1,14 +1,15 @@ /** * Where a domain came from / how it is managed. * - * - 'manual' → operator added the domain manually. dcrouter is the - * authoritative DNS server for it; records are served by - * the embedded smartdns.DnsServer. + * - 'dcrouter' → dcrouter is the authoritative DNS server for this domain; + * records are served by the embedded smartdns.DnsServer. + * Operators delegate the domain's NS records to make this + * effective. * - 'provider' → domain was imported from an external DNS provider - * (e.g. Cloudflare). The provider stays authoritative; - * dcrouter only reads/writes records via the provider API. + * (e.g. Cloudflare). The provider stays authoritative; + * dcrouter only reads/writes records via the provider API. */ -export type TDomainSource = 'manual' | 'provider'; +export type TDomainSource = 'dcrouter' | 'provider'; /** * A domain under management by dcrouter. @@ -20,7 +21,7 @@ export interface IDomain { source: TDomainSource; /** ID of the DnsProvider that owns this domain — only set when source === 'provider'. */ providerId?: string; - /** True when dcrouter is the authoritative DNS server for this domain (source === 'manual'). */ + /** True when dcrouter is the authoritative DNS server for this domain (source === 'dcrouter'). */ authoritative: boolean; /** Authoritative nameservers (display only — populated from provider for imported domains). */ nameservers?: string[]; diff --git a/ts_interfaces/requests/dns-records.ts b/ts_interfaces/requests/dns-records.ts index 704edbd..914ac9a 100644 --- a/ts_interfaces/requests/dns-records.ts +++ b/ts_interfaces/requests/dns-records.ts @@ -45,7 +45,7 @@ export interface IReq_GetDnsRecord extends plugins.typedrequestInterfaces.implem /** * Create a new DNS record. * - * For manual domains: registers the record with the embedded DnsServer. + * For dcrouter-hosted domains: registers the record with the embedded DnsServer. * For provider domains: pushes the record to the provider API. */ export interface IReq_CreateDnsRecord extends plugins.typedrequestInterfaces.implementsTR< diff --git a/ts_interfaces/requests/domains.ts b/ts_interfaces/requests/domains.ts index 3597953..b39f429 100644 --- a/ts_interfaces/requests/domains.ts +++ b/ts_interfaces/requests/domains.ts @@ -42,8 +42,8 @@ export interface IReq_GetDomain extends plugins.typedrequestInterfaces.implement } /** - * 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. */ export interface IReq_CreateDomain extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, @@ -130,7 +130,7 @@ export interface IReq_DeleteDomain extends plugins.typedrequestInterfaces.implem /** * Force-resync a provider-managed domain: re-pulls all records from the * provider API, replacing the cached DnsRecordDocs. - * No-op for manual domains. + * No-op for dcrouter-hosted domains. */ export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, diff --git a/ts_migrations/index.ts b/ts_migrations/index.ts index 5e5daf0..d1a33db 100644 --- a/ts_migrations/index.ts +++ b/ts_migrations/index.ts @@ -64,6 +64,34 @@ export async function createMigrationRunner( migrated++; } ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`); + }) + .step('rename-domain-source-manual-to-dcrouter') + .from('13.1.0').to('13.8.1') + .description('Rename DomainDoc.source value from "manual" to "dcrouter"') + .up(async (ctx) => { + const collection = ctx.mongo!.collection('domaindoc'); + const result = await collection.updateMany( + { source: 'manual' }, + { $set: { source: 'dcrouter' } }, + ); + ctx.log.log( + 'info', + `rename-domain-source-manual-to-dcrouter: migrated ${result.modifiedCount} domain(s)`, + ); + }) + .step('rename-record-source-manual-to-local') + .from('13.8.1').to('13.8.2') + .description('Rename DnsRecordDoc.source value from "manual" to "local"') + .up(async (ctx) => { + const collection = ctx.mongo!.collection('dnsrecorddoc'); + const result = await collection.updateMany( + { source: 'manual' }, + { $set: { source: 'local' } }, + ); + ctx.log.log( + 'info', + `rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`, + ); }); return migration; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 1568d6e..1c60ccc 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.8.0', + version: '13.9.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 2be0122..c776b04 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -1793,7 +1793,7 @@ export async function fetchProviderDomains( return await request.fire({ identity: context.identity, providerId }); } -export const createManualDomainAction = domainsStatePart.createAction<{ +export const createDcrouterDomainAction = domainsStatePart.createAction<{ name: string; description?: string; }>(async (statePartArg, dataArg, actionContext): Promise => { diff --git a/ts_web/elements/domains/dns-provider-form.ts b/ts_web/elements/domains/dns-provider-form.ts index 879ce0e..9eb7df9 100644 --- a/ts_web/elements/domains/dns-provider-form.ts +++ b/ts_web/elements/domains/dns-provider-form.ts @@ -44,12 +44,15 @@ export class DnsProviderForm extends DeesElement { accessor providerName: string = ''; /** - * Currently selected provider type. Initialized to the first descriptor; - * caller can override before mounting (e.g. for edit dialogs). + * Currently selected provider type. Initialized to the first user-creatable + * descriptor; caller can override before mounting (e.g. for edit dialogs). + * The built-in 'dcrouter' pseudo-provider is excluded from the picker — + * operators cannot create another one. */ @state() accessor selectedType: interfaces.data.TDnsProviderType = - interfaces.data.dnsProviderTypeDescriptors[0]?.type ?? 'cloudflare'; + interfaces.data.dnsProviderTypeDescriptors.find((d) => d.type !== 'dcrouter')?.type ?? + 'cloudflare'; /** When true, hide the type picker — used in edit dialogs. */ @property({ type: Boolean }) @@ -102,7 +105,12 @@ export class DnsProviderForm extends DeesElement { ]; public render(): TemplateResult { - const descriptors = interfaces.data.dnsProviderTypeDescriptors; + // Exclude the built-in 'dcrouter' pseudo-provider from the type picker — + // operators cannot create another one, it's surfaced at read time by the + // backend handler instead. + const descriptors = interfaces.data.dnsProviderTypeDescriptors.filter( + (d) => d.type !== 'dcrouter', + ); const descriptor = interfaces.data.getDnsProviderTypeDescriptor(this.selectedType); return html` diff --git a/ts_web/elements/domains/ops-view-dns.ts b/ts_web/elements/domains/ops-view-dns.ts index c0cda0c..ae92de1 100644 --- a/ts_web/elements/domains/ops-view-dns.ts +++ b/ts_web/elements/domains/ops-view-dns.ts @@ -80,7 +80,7 @@ export class OpsViewDns extends DeesElement { font-weight: 500; } - .sourceBadge.manual { + .sourceBadge.local { background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')}; color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')}; } @@ -184,7 +184,7 @@ export class OpsViewDns extends DeesElement { private domainHint(domainId: string): string { const domain = this.domainsState.domains.find((d) => d.id === domainId); if (!domain) return ''; - if (domain.source === 'manual') { + if (domain.source === 'dcrouter') { return 'Records are served by dcrouter (authoritative).'; } return 'Records are stored at the provider — changes here are pushed via the provider API.'; diff --git a/ts_web/elements/domains/ops-view-domains.ts b/ts_web/elements/domains/ops-view-domains.ts index 27e0c83..0d29e80 100644 --- a/ts_web/elements/domains/ops-view-domains.ts +++ b/ts_web/elements/domains/ops-view-domains.ts @@ -55,7 +55,7 @@ export class OpsViewDomains extends DeesElement { font-weight: 500; } - .sourceBadge.manual { + .sourceBadge.dcrouter { background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')}; color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')}; } @@ -76,7 +76,7 @@ export class OpsViewDomains extends DeesElement {
({ @@ -90,11 +90,11 @@ export class OpsViewDomains extends DeesElement { })} .dataActions=${[ { - name: 'Add Manual Domain', + name: 'Add DcRouter Domain', iconName: 'lucide:plus', type: ['header' as const], actionFunc: async () => { - await this.showCreateManualDialog(); + await this.showCreateDcrouterDialog(); }, }, { @@ -168,17 +168,17 @@ export class OpsViewDomains extends DeesElement { d: interfaces.data.IDomain, providersById: Map, ): TemplateResult { - if (d.source === 'manual') { - return html`Manual`; + if (d.source === 'dcrouter') { + return html`DcRouter`; } const provider = d.providerId ? providersById.get(d.providerId) : undefined; return html`${provider?.name || 'Provider'}`; } - private async showCreateManualDialog() { + private async showCreateDcrouterDialog() { const { DeesModal } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({ - heading: 'Add Manual Domain', + heading: 'Add DcRouter Domain', content: html` @@ -199,7 +199,7 @@ export class OpsViewDomains extends DeesElement { ?.querySelector('dees-form'); if (!form) return; const data = await form.collectFormData(); - await appstate.domainsStatePart.dispatchAction(appstate.createManualDomainAction, { + await appstate.domainsStatePart.dispatchAction(appstate.createDcrouterDomainAction, { name: String(data.name), description: data.description ? String(data.description) : undefined, }); diff --git a/ts_web/elements/domains/ops-view-providers.ts b/ts_web/elements/domains/ops-view-providers.ts index fd0acbf..bafd047 100644 --- a/ts_web/elements/domains/ops-view-providers.ts +++ b/ts_web/elements/domains/ops-view-providers.ts @@ -71,6 +71,11 @@ export class OpsViewProviders extends DeesElement { background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; color: ${cssManager.bdTheme('#4b5563', '#9ca3af')}; } + + .statusBadge.builtin { + background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')}; + color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')}; + } `, ]; @@ -82,15 +87,21 @@ export class OpsViewProviders extends DeesElement {
({ Name: p.name, Type: this.providerTypeLabel(p.type), - Status: this.renderStatusBadge(p.status), - 'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never', - Error: p.lastError || '-', + Status: p.builtIn + ? html`built-in` + : this.renderStatusBadge(p.status), + 'Last Tested': p.builtIn + ? '—' + : p.lastTestedAt + ? new Date(p.lastTestedAt).toLocaleString() + : 'never', + Error: p.builtIn ? '—' : p.lastError || '-', })} .dataActions=${[ { @@ -116,6 +127,7 @@ export class OpsViewProviders extends DeesElement { name: 'Test Connection', iconName: 'lucide:plug', type: ['inRow', 'contextmenu'] as any, + actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn, actionFunc: async (actionData: any) => { const provider = actionData.item as interfaces.data.IDnsProviderPublic; await this.testProvider(provider); @@ -125,6 +137,7 @@ export class OpsViewProviders extends DeesElement { name: 'Edit', iconName: 'lucide:pencil', type: ['inRow', 'contextmenu'] as any, + actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn, actionFunc: async (actionData: any) => { const provider = actionData.item as interfaces.data.IDnsProviderPublic; await this.showEditDialog(provider); @@ -134,6 +147,7 @@ export class OpsViewProviders extends DeesElement { name: 'Delete', iconName: 'lucide:trash2', type: ['inRow', 'contextmenu'] as any, + actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn, actionFunc: async (actionData: any) => { const provider = actionData.item as interfaces.data.IDnsProviderPublic; await this.deleteProvider(provider);