diff --git a/changelog.md b/changelog.md index 5bdc69d..ca927c0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-08 - 13.6.0 - feat(dns) +add db-backed DNS provider, domain, and record management with ops UI support + +- introduce DnsManager-backed persistence for DNS providers, domains, and records with Cloudflare provider support +- replace constructor-based ACME DNS challenge configuration with provider records stored in the database +- add opsserver typed request handlers and API token scopes for managing DNS providers, domains, and records +- add a new Domains section in the ops UI for providers, domains, DNS records, and certificates + ## 2026-04-08 - 13.5.0 - feat(opsserver-access) add admin user listing to the access dashboard diff --git a/package.json b/package.json index bcdbeb3..f4c83a0 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.68.0", + "@design.estate/dees-catalog": "^3.69.1", "@design.estate/dees-element": "^2.2.4", "@push.rocks/lik": "^6.4.0", "@push.rocks/projectinfo": "^5.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c400c7..15a7775 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.68.0 - version: 3.68.0(@tiptap/pm@2.27.2) + specifier: ^3.69.1 + version: 3.69.1(@tiptap/pm@2.27.2) '@design.estate/dees-element': specifier: ^2.2.4 version: 2.2.4 @@ -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.68.0': - resolution: {integrity: sha512-4jTq/pZmhLFS2jGsF8I+bqLV+P4O9bBAyNtF5Ga1omNCwZFQmITiwPZ2brOGvVFaVrMDi8VdY4I7FTMofF7Diw==} + '@design.estate/dees-catalog@3.69.1': + resolution: {integrity: sha512-OSpHB/hfOrL2mkAfF50TqTKJ2hvPd7Cj1WklAmFckyjloE4fd7DRDeXdI/Bziq9152gExipX5VoofTAOr4rF5w==} '@design.estate/dees-comms@1.0.30': resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} @@ -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.68.0(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.69.1(@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.68.0(@tiptap/pm@2.27.2)': + '@design.estate/dees-catalog@3.69.1(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-element': 2.2.4 @@ -6900,7 +6900,7 @@ snapshots: '@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)': dependencies: - '@design.estate/dees-catalog': 3.68.0(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.69.1(@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 5ad42f6..1b4d364 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.5.0', + version: '13.6.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 1db5532..eb5f213 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -27,6 +27,7 @@ import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; +import { DnsManager } from './dns/manager.dns.js'; export interface IDcRouterOptions { /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ @@ -116,13 +117,6 @@ export interface IDcRouterOptions { useIngressProxy?: boolean; // Whether to replace server IP with proxy IP (default: true) }>; - /** DNS challenge configuration for ACME (optional) */ - dnsChallenge?: { - /** Cloudflare API key for DNS challenges */ - cloudflareApiKey?: string; - /** Other DNS providers can be added here */ - }; - /** * Unified database configuration. * All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata. @@ -279,6 +273,9 @@ export class DcRouter { public referenceResolver?: ReferenceResolver; public targetProfileManager?: TargetProfileManager; + // Domain / DNS management (DB-backed providers, domains, records) + public dnsManager?: DnsManager; + // Auto-discovered public IP (populated by generateAuthoritativeRecords) public detectedPublicIp: string | null = null; @@ -393,10 +390,33 @@ export class DcRouter { .withRetry({ maxRetries: 1, baseDelayMs: 1000 }), ); - // SmartProxy: critical, depends on DcRouterDb (if enabled) + // DnsManager: optional, depends on DcRouterDb — owns DB-backed DNS state + // (providers, domains, records). Must run before SmartProxy so ACME DNS-01 + // wiring can look up providers. + if (this.options.dbConfig?.enabled !== false) { + this.serviceManager.addService( + new plugins.taskbuffer.Service('DnsManager') + .optional() + .dependsOn('DcRouterDb') + .withStart(async () => { + this.dnsManager = new DnsManager(this.options); + await this.dnsManager.start(); + }) + .withStop(async () => { + if (this.dnsManager) { + await this.dnsManager.stop(); + this.dnsManager = undefined; + } + }) + .withRetry({ maxRetries: 1, baseDelayMs: 500 }), + ); + } + + // SmartProxy: critical, depends on DcRouterDb + DnsManager (if enabled) const smartProxyDeps: string[] = []; if (this.options.dbConfig?.enabled !== false) { smartProxyDeps.push('DcRouterDb'); + smartProxyDeps.push('DnsManager'); } this.serviceManager.addService( new plugins.taskbuffer.Service('SmartProxy') @@ -415,9 +435,11 @@ export class DcRouter { .withRetry({ maxRetries: 0 }), ); - // SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits - // Only registered if DNS challenge is configured - if (this.options.dnsChallenge?.cloudflareApiKey) { + // SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits. + // Always registered when the DB is enabled; setupSmartProxy() decides whether + // to actually instantiate SmartAcme based on whether any DnsProviderDoc exists. + // If `this.smartAcme` is unset by the time this service starts, withStart is a no-op. + if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('SmartAcme') .optional() @@ -849,12 +871,14 @@ export class DcRouter { }; } - // Configure DNS challenge if available + // Configure DNS-01 challenge if any DnsProviderDoc exists in the DB. + // The DnsManager dispatches each challenge to the right provider client + // based on the FQDN being certificated. let challengeHandlers: any[] = []; - if (this.options.dnsChallenge?.cloudflareApiKey) { - logger.log('info', 'Configuring Cloudflare DNS challenge for ACME'); - const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey); - const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount); + if (this.dnsManager && (await this.dnsManager.hasAcmeCapableProvider())) { + logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)'); + const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider(); + const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider); challengeHandlers.push(dns01Handler); } @@ -1720,8 +1744,13 @@ export class DcRouter { this.registerDnsRecords(allRecords); 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. + if (this.dnsManager && this.dnsServer) { + await this.dnsManager.attachDnsServer(this.dnsServer); + } } - + /** * Create DNS socket handler for DoH */ diff --git a/ts/db/documents/classes.dns-provider.doc.ts b/ts/db/documents/classes.dns-provider.doc.ts new file mode 100644 index 0000000..d9df008 --- /dev/null +++ b/ts/db/documents/classes.dns-provider.doc.ts @@ -0,0 +1,63 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { + TDnsProviderType, + TDnsProviderStatus, + TDnsProviderCredentials, +} from '../../../ts_interfaces/data/dns-provider.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc { + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id!: string; + + @plugins.smartdata.svDb() + public name: string = ''; + + @plugins.smartdata.svDb() + public type!: TDnsProviderType; + + /** + * Provider credentials, persisted as an opaque object. Shape varies by `type`. + * Never returned to the UI — handlers map to IDnsProviderPublic before sending. + */ + @plugins.smartdata.svDb() + public credentials!: TDnsProviderCredentials; + + @plugins.smartdata.svDb() + public status: TDnsProviderStatus = 'untested'; + + @plugins.smartdata.svDb() + public lastTestedAt?: number; + + @plugins.smartdata.svDb() + public lastError?: string; + + @plugins.smartdata.svDb() + public createdAt!: number; + + @plugins.smartdata.svDb() + public updatedAt!: number; + + @plugins.smartdata.svDb() + public createdBy!: string; + + constructor() { + super(); + } + + public static async findById(id: string): Promise { + return await DnsProviderDoc.getInstance({ id }); + } + + public static async findAll(): Promise { + return await DnsProviderDoc.getInstances({}); + } + + public static async findByType(type: TDnsProviderType): Promise { + return await DnsProviderDoc.getInstances({ type }); + } +} diff --git a/ts/db/documents/classes.dns-record.doc.ts b/ts/db/documents/classes.dns-record.doc.ts new file mode 100644 index 0000000..e33da35 --- /dev/null +++ b/ts/db/documents/classes.dns-record.doc.ts @@ -0,0 +1,62 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc { + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id!: string; + + @plugins.smartdata.svDb() + public domainId!: string; + + /** FQDN of the record (e.g. 'www.example.com'). */ + @plugins.smartdata.svDb() + public name: string = ''; + + @plugins.smartdata.svDb() + public type!: TDnsRecordType; + + @plugins.smartdata.svDb() + public value!: string; + + @plugins.smartdata.svDb() + public ttl: number = 300; + + @plugins.smartdata.svDb() + public proxied?: boolean; + + @plugins.smartdata.svDb() + public source!: TDnsRecordSource; + + @plugins.smartdata.svDb() + public providerRecordId?: string; + + @plugins.smartdata.svDb() + public createdAt!: number; + + @plugins.smartdata.svDb() + public updatedAt!: number; + + @plugins.smartdata.svDb() + public createdBy!: string; + + constructor() { + super(); + } + + public static async findById(id: string): Promise { + return await DnsRecordDoc.getInstance({ id }); + } + + public static async findAll(): Promise { + return await DnsRecordDoc.getInstances({}); + } + + public static async findByDomainId(domainId: string): Promise { + return await DnsRecordDoc.getInstances({ domainId }); + } +} diff --git a/ts/db/documents/classes.domain.doc.ts b/ts/db/documents/classes.domain.doc.ts new file mode 100644 index 0000000..d368f65 --- /dev/null +++ b/ts/db/documents/classes.domain.doc.ts @@ -0,0 +1,66 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { TDomainSource } from '../../../ts_interfaces/data/domain.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class DomainDoc extends plugins.smartdata.SmartDataDbDoc { + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id!: string; + + /** FQDN — kept lowercased on save. */ + @plugins.smartdata.svDb() + public name: string = ''; + + @plugins.smartdata.svDb() + public source!: TDomainSource; + + @plugins.smartdata.svDb() + public providerId?: string; + + @plugins.smartdata.svDb() + public authoritative: boolean = false; + + @plugins.smartdata.svDb() + public nameservers?: string[]; + + @plugins.smartdata.svDb() + public externalZoneId?: string; + + @plugins.smartdata.svDb() + public lastSyncedAt?: number; + + @plugins.smartdata.svDb() + public description?: string; + + @plugins.smartdata.svDb() + public createdAt!: number; + + @plugins.smartdata.svDb() + public updatedAt!: number; + + @plugins.smartdata.svDb() + public createdBy!: string; + + constructor() { + super(); + } + + public static async findById(id: string): Promise { + return await DomainDoc.getInstance({ id }); + } + + public static async findByName(name: string): Promise { + return await DomainDoc.getInstance({ name: name.toLowerCase() }); + } + + public static async findAll(): Promise { + return await DomainDoc.getInstances({}); + } + + public static async findByProviderId(providerId: string): Promise { + return await DomainDoc.getInstances({ providerId }); + } +} diff --git a/ts/db/documents/index.ts b/ts/db/documents/index.ts index 69625f6..f44fe58 100644 --- a/ts/db/documents/index.ts +++ b/ts/db/documents/index.ts @@ -25,3 +25,8 @@ export * from './classes.remote-ingress-edge.doc.js'; // RADIUS document classes export * from './classes.vlan-mappings.doc.js'; export * from './classes.accounting-session.doc.js'; + +// DNS / Domain management document classes +export * from './classes.dns-provider.doc.js'; +export * from './classes.domain.doc.js'; +export * from './classes.dns-record.doc.js'; diff --git a/ts/dns/index.ts b/ts/dns/index.ts new file mode 100644 index 0000000..932c64b --- /dev/null +++ b/ts/dns/index.ts @@ -0,0 +1,2 @@ +export * from './manager.dns.js'; +export * from './providers/index.js'; diff --git a/ts/dns/manager.dns.ts b/ts/dns/manager.dns.ts new file mode 100644 index 0000000..2cfb2c1 --- /dev/null +++ b/ts/dns/manager.dns.ts @@ -0,0 +1,867 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import { + DnsProviderDoc, + DomainDoc, + DnsRecordDoc, +} from '../db/documents/index.js'; +import type { IDcRouterOptions } from '../classes.dcrouter.js'; +import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js'; +import { createDnsProvider } from './providers/factory.js'; +import type { + TDnsRecordType, + TDnsRecordSource, +} from '../../ts_interfaces/data/dns-record.js'; +import type { + TDnsProviderType, + TDnsProviderCredentials, + IDnsProviderPublic, + IProviderDomainListing, +} from '../../ts_interfaces/data/dns-provider.js'; + +/** + * DnsManager — owns runtime DNS state on top of the embedded DnsServer. + * + * 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) + * - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy() + * + * Provider-managed domains are NEVER served from the embedded DnsServer — the + * provider stays authoritative. We only mirror their records locally for the UI + * and to track providerRecordIds for updates / deletes. + */ +export class DnsManager { + /** + * Reference to the active smartdns DnsServer (set by DcRouter once it exists). + * May be undefined if dnsScopes/dnsNsDomains aren't configured. + */ + public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer; + + /** + * Cached provider clients, keyed by DnsProviderDoc.id. + * Created lazily when a provider is first needed. + */ + private providerClients = new Map(); + + constructor(private options: IDcRouterOptions) {} + + // ========================================================================== + // Lifecycle + // ========================================================================== + + /** + * Called from DcRouter after DcRouterDb is up. Performs first-boot seeding + * from legacy constructor config if (and only if) the DB is empty. + */ + public async start(): Promise { + logger.log('info', 'DnsManager: starting'); + await this.seedFromConstructorConfigIfEmpty(); + } + + public async stop(): Promise { + this.providerClients.clear(); + this.dnsServer = undefined; + } + + /** + * 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. + */ + public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise { + this.dnsServer = dnsServer; + await this.applyManualDomainsToDnsServer(); + } + + // ========================================================================== + // First-boot seeding + // ========================================================================== + + /** + * If no DomainDocs exist yet but the constructor has legacy DNS fields, + * seed them as `source: 'manual'` records. On subsequent boots (DB has + * entries), constructor config is ignored with a warning. + */ + private async seedFromConstructorConfigIfEmpty(): Promise { + const existingDomains = await DomainDoc.findAll(); + const hasLegacyConfig = + (this.options.dnsScopes && this.options.dnsScopes.length > 0) || + (this.options.dnsRecords && this.options.dnsRecords.length > 0); + + if (existingDomains.length > 0) { + if (hasLegacyConfig) { + logger.log( + 'warn', + 'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' + + 'Manage DNS via the Domains UI instead.', + ); + } + return; + } + + if (!hasLegacyConfig) { + return; + } + + logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config'); + + const now = Date.now(); + const seededDomains = new Map(); + + // Create one DomainDoc per dnsScope (these are the authoritative zones) + for (const scope of this.options.dnsScopes ?? []) { + const domain = new DomainDoc(); + domain.id = plugins.uuid.v4(); + domain.name = scope.toLowerCase(); + domain.source = 'manual'; + domain.authoritative = true; + domain.createdAt = now; + domain.updatedAt = now; + domain.createdBy = 'seed'; + await domain.save(); + seededDomains.set(domain.name, domain); + logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`); + } + + // Map each legacy dnsRecord to its parent DomainDoc + for (const rec of this.options.dnsRecords ?? []) { + const parent = this.findParentDomain(rec.name, seededDomains); + if (!parent) { + logger.log( + 'warn', + `DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`, + ); + continue; + } + const record = new DnsRecordDoc(); + record.id = plugins.uuid.v4(); + record.domainId = parent.id; + record.name = rec.name.toLowerCase(); + record.type = rec.type as TDnsRecordType; + record.value = rec.value; + record.ttl = rec.ttl ?? 300; + record.source = 'manual'; + record.createdAt = now; + record.updatedAt = now; + record.createdBy = 'seed'; + await record.save(); + } + + logger.log( + 'info', + `DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`, + ); + } + + private findParentDomain( + recordName: string, + domains: Map, + ): DomainDoc | null { + const lower = recordName.toLowerCase().replace(/^\*\./, ''); + let candidate: DomainDoc | null = null; + for (const [name, doc] of domains) { + if (lower === name || lower.endsWith(`.${name}`)) { + if (!candidate || name.length > candidate.name.length) { + candidate = doc; + } + } + } + return candidate; + } + + // ========================================================================== + // Manual-domain DnsServer wiring + // ========================================================================== + + /** + * Register all manual-domain records from the DB with the embedded DnsServer. + * Called once after attachDnsServer(). + */ + private async applyManualDomainsToDnsServer(): Promise { + if (!this.dnsServer) { + return; + } + const allDomains = await DomainDoc.findAll(); + const manualDomains = allDomains.filter((d) => d.source === 'manual'); + let registered = 0; + for (const domain of manualDomains) { + 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`); + } + + /** + * Register a single record with the embedded DnsServer. The handler closure + * captures the record fields, so updates require a re-register cycle. + */ + private registerRecordWithDnsServer(rec: DnsRecordDoc): void { + if (!this.dnsServer) return; + this.dnsServer.registerHandler(rec.name, [rec.type], (question) => { + if (question.name === rec.name && question.type === rec.type) { + return { + name: rec.name, + type: rec.type, + class: 'IN', + ttl: rec.ttl, + data: this.parseRecordData(rec.type, rec.value), + }; + } + return null; + }); + } + + private parseRecordData(type: TDnsRecordType, value: string): any { + switch (type) { + case 'A': + case 'AAAA': + case 'CNAME': + case 'TXT': + case 'NS': + case 'CAA': + return value; + case 'MX': { + const [priorityStr, exchange] = value.split(' '); + return { priority: parseInt(priorityStr, 10), exchange }; + } + case 'SOA': { + const parts = value.split(' '); + return { + mname: parts[0], + rname: parts[1], + serial: parseInt(parts[2], 10), + refresh: parseInt(parts[3], 10), + retry: parseInt(parts[4], 10), + expire: parseInt(parts[5], 10), + minimum: parseInt(parts[6], 10), + }; + } + default: + return value; + } + } + + // ========================================================================== + // Provider lookup (used by ACME DNS-01 + record CRUD) + // ========================================================================== + + /** + * Get the provider client for a given DnsProviderDoc id, instantiating + * (and caching) it on first use. + */ + public async getProviderClientById(providerId: string): Promise { + const cached = this.providerClients.get(providerId); + if (cached) return cached; + const doc = await DnsProviderDoc.findById(providerId); + if (!doc) return null; + const client = createDnsProvider(doc.type, doc.credentials); + this.providerClients.set(providerId, client); + return client; + } + + /** + * Find the IDnsProviderClient that owns the given FQDN (by walking up its + * labels to find a matching DomainDoc with `source === 'provider'`). + * Returns null if no provider claims this FQDN. + * + * Used by: + * - SmartAcme DNS-01 wiring in setupSmartProxy() + * - DnsRecordHandler when creating provider records + */ + public async getProviderClientForDomain(fqdn: string): Promise { + const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, ''); + const allDomains = await DomainDoc.findAll(); + const providerDomains = allDomains + .filter((d) => d.source === 'provider' && d.providerId) + // longest-match wins + .sort((a, b) => b.name.length - a.name.length); + + for (const domain of providerDomains) { + if (lower === domain.name || lower.endsWith(`.${domain.name}`)) { + return this.getProviderClientById(domain.providerId!); + } + } + return null; + } + + /** + * True if any cloudflare provider exists in the DB. Used by setupSmartProxy() + * to decide whether to wire SmartAcme with a DNS-01 handler. + */ + public async hasAcmeCapableProvider(): Promise { + const providers = await DnsProviderDoc.findAll(); + return providers.length > 0; + } + + /** + * Build an IConvenientDnsProvider that dispatches each ACME challenge to + * the right CloudflareDnsProvider based on the challenge's hostName. + * Returned object plugs directly into smartacme's Dns01Handler. + */ + public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider { + const self = this; + const adapter = { + async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) { + const client = await self.getProviderClientForDomain(dnsChallenge.hostName); + if (!client) { + throw new Error( + `DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` + + 'Add one in the Domains > Providers UI before issuing certificates.', + ); + } + // Clean any leftover challenge records first to avoid duplicates. + try { + const existing = await client.listRecords(dnsChallenge.hostName); + for (const r of existing) { + if (r.type === 'TXT' && r.name === dnsChallenge.hostName) { + await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {}); + } + } + } catch (err: unknown) { + logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`); + } + await client.createRecord(dnsChallenge.hostName, { + name: dnsChallenge.hostName, + type: 'TXT', + value: dnsChallenge.challenge, + ttl: 120, + }); + }, + async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) { + const client = await self.getProviderClientForDomain(dnsChallenge.hostName); + if (!client) { + // The domain may have been removed; nothing to clean up. + return; + } + try { + const existing = await client.listRecords(dnsChallenge.hostName); + for (const r of existing) { + if (r.type === 'TXT' && r.name === dnsChallenge.hostName) { + await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId); + } + } + } catch (err: unknown) { + logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`); + } + }, + async isDomainSupported(domain: string): Promise { + const client = await self.getProviderClientForDomain(domain); + return !!client; + }, + }; + return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider; + } + + // ========================================================================== + // Provider CRUD (used by DnsProviderHandler) + // ========================================================================== + + public async listProviders(): Promise { + const docs = await DnsProviderDoc.findAll(); + return docs.map((d) => this.toPublicProvider(d)); + } + + public async getProvider(id: string): Promise { + const doc = await DnsProviderDoc.findById(id); + return doc ? this.toPublicProvider(doc) : null; + } + + public async createProvider(args: { + name: string; + type: TDnsProviderType; + credentials: TDnsProviderCredentials; + createdBy: string; + }): Promise { + const now = Date.now(); + const doc = new DnsProviderDoc(); + doc.id = plugins.uuid.v4(); + doc.name = args.name; + doc.type = args.type; + doc.credentials = args.credentials; + doc.status = 'untested'; + doc.createdAt = now; + doc.updatedAt = now; + doc.createdBy = args.createdBy; + await doc.save(); + return doc.id; + } + + public async updateProvider( + id: string, + args: { name?: string; credentials?: TDnsProviderCredentials }, + ): Promise { + const doc = await DnsProviderDoc.findById(id); + if (!doc) return false; + if (args.name !== undefined) doc.name = args.name; + if (args.credentials !== undefined) { + doc.credentials = args.credentials; + doc.status = 'untested'; + doc.lastError = undefined; + // Invalidate cached client so the next use re-instantiates with the new credentials. + this.providerClients.delete(id); + } + doc.updatedAt = Date.now(); + await doc.save(); + return true; + } + + public async deleteProvider(id: string, force: boolean): Promise<{ success: boolean; message?: string }> { + const doc = await DnsProviderDoc.findById(id); + if (!doc) return { success: false, message: 'Provider not found' }; + const linkedDomains = await DomainDoc.findByProviderId(id); + if (linkedDomains.length > 0 && !force) { + return { + success: false, + message: `Provider is referenced by ${linkedDomains.length} domain(s). Pass force: true to delete anyway.`, + }; + } + // If forcing, also delete the linked domains and their records. + if (force) { + for (const domain of linkedDomains) { + await this.deleteDomain(domain.id); + } + } + await doc.delete(); + this.providerClients.delete(id); + return { success: true }; + } + + public async testProvider(id: string): Promise<{ ok: boolean; error?: string; testedAt: number }> { + const doc = await DnsProviderDoc.findById(id); + if (!doc) { + return { ok: false, error: 'Provider not found', testedAt: Date.now() }; + } + const client = createDnsProvider(doc.type, doc.credentials); + const result = await client.testConnection(); + doc.status = result.ok ? 'ok' : 'error'; + doc.lastTestedAt = Date.now(); + doc.lastError = result.ok ? undefined : result.error; + await doc.save(); + if (result.ok) { + this.providerClients.set(id, client); // cache the working client + } + return { ok: result.ok, error: result.error, testedAt: doc.lastTestedAt }; + } + + public async listProviderDomains(providerId: string): Promise { + const client = await this.getProviderClientById(providerId); + if (!client) { + throw new Error('Provider not found'); + } + return await client.listDomains(); + } + + // ========================================================================== + // Domain CRUD (used by DomainHandler) + // ========================================================================== + + public async listDomains(): Promise { + return await DomainDoc.findAll(); + } + + public async getDomain(id: string): Promise { + return await DomainDoc.findById(id); + } + + /** + * Create a manual (authoritative) domain. dcrouter will serve DNS records + * for this domain via the embedded smartdns.DnsServer. + */ + public async createManualDomain(args: { + name: string; + description?: string; + createdBy: string; + }): Promise { + const now = Date.now(); + const doc = new DomainDoc(); + doc.id = plugins.uuid.v4(); + doc.name = args.name.toLowerCase(); + doc.source = 'manual'; + doc.authoritative = true; + doc.description = args.description; + doc.createdAt = now; + doc.updatedAt = now; + doc.createdBy = args.createdBy; + await doc.save(); + return doc.id; + } + + /** + * Import one or more domains from a provider, pulling all of their DNS + * records into local DnsRecordDocs. + */ + public async importDomainsFromProvider(args: { + providerId: string; + domainNames: string[]; + createdBy: string; + }): Promise { + const provider = await DnsProviderDoc.findById(args.providerId); + if (!provider) { + throw new Error('Provider not found'); + } + const client = await this.getProviderClientById(args.providerId); + if (!client) { + throw new Error('Failed to instantiate provider client'); + } + const allProviderDomains = await client.listDomains(); + const importedIds: string[] = []; + const now = Date.now(); + + for (const wantedName of args.domainNames) { + const lower = wantedName.toLowerCase(); + const listing = allProviderDomains.find((d) => d.name.toLowerCase() === lower); + if (!listing) { + logger.log('warn', `DnsManager: import skipped — provider does not list domain ${wantedName}`); + continue; + } + // Skip if already imported + const existing = await DomainDoc.findByName(lower); + if (existing) { + logger.log('warn', `DnsManager: domain ${wantedName} already imported — skipping`); + continue; + } + + const domain = new DomainDoc(); + domain.id = plugins.uuid.v4(); + domain.name = lower; + domain.source = 'provider'; + domain.providerId = args.providerId; + domain.authoritative = false; + domain.nameservers = listing.nameservers; + domain.externalZoneId = listing.externalId; + domain.lastSyncedAt = now; + domain.createdAt = now; + domain.updatedAt = now; + domain.createdBy = args.createdBy; + await domain.save(); + importedIds.push(domain.id); + + // Pull records for the imported domain + try { + const providerRecords = await client.listRecords(lower); + for (const pr of providerRecords) { + await this.createSyncedRecord(domain.id, pr, args.createdBy); + } + logger.log('info', `DnsManager: imported ${providerRecords.length} record(s) for ${lower}`); + } catch (err: unknown) { + logger.log('warn', `DnsManager: failed to import records for ${lower}: ${(err as Error).message}`); + } + } + return importedIds; + } + + public async updateDomain(id: string, args: { description?: string }): Promise { + const doc = await DomainDoc.findById(id); + if (!doc) return false; + if (args.description !== undefined) doc.description = args.description; + doc.updatedAt = Date.now(); + await doc.save(); + return true; + } + + /** + * 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. + * + * 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 + * is the source of truth and the next start will not register the deleted + * record. + */ + public async deleteDomain(id: string): Promise { + const doc = await DomainDoc.findById(id); + if (!doc) return false; + const records = await DnsRecordDoc.findByDomainId(id); + for (const r of records) { + await r.delete(); + } + await doc.delete(); + return true; + } + + /** + * Force-resync a provider-managed domain: re-pull all records from the + * provider API, replacing the cached DnsRecordDocs. + */ + public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; message?: string }> { + const doc = await DomainDoc.findById(id); + if (!doc) return { success: false, message: 'Domain not found' }; + if (doc.source !== 'provider' || !doc.providerId) { + return { success: false, message: 'Domain is not provider-managed' }; + } + const client = await this.getProviderClientById(doc.providerId); + if (!client) { + return { success: false, message: 'Provider client unavailable' }; + } + const providerRecords = await client.listRecords(doc.name); + + // Drop existing records and replace + const existing = await DnsRecordDoc.findByDomainId(id); + for (const r of existing) { + await r.delete(); + } + for (const pr of providerRecords) { + await this.createSyncedRecord(id, pr, doc.createdBy); + } + doc.lastSyncedAt = Date.now(); + doc.updatedAt = doc.lastSyncedAt; + await doc.save(); + return { success: true, recordCount: providerRecords.length }; + } + + // ========================================================================== + // Record CRUD (used by DnsRecordHandler) + // ========================================================================== + + public async listRecordsForDomain(domainId: string): Promise { + return await DnsRecordDoc.findByDomainId(domainId); + } + + public async getRecord(id: string): Promise { + return await DnsRecordDoc.findById(id); + } + + public async createRecord(args: { + domainId: string; + name: string; + type: TDnsRecordType; + value: string; + ttl?: number; + proxied?: boolean; + createdBy: string; + }): Promise<{ success: boolean; id?: string; message?: string }> { + const domain = await DomainDoc.findById(args.domainId); + if (!domain) return { success: false, message: 'Domain not found' }; + + const now = Date.now(); + const doc = new DnsRecordDoc(); + doc.id = plugins.uuid.v4(); + doc.domainId = args.domainId; + doc.name = args.name.toLowerCase(); + doc.type = args.type; + doc.value = args.value; + doc.ttl = args.ttl ?? 300; + if (args.proxied !== undefined) doc.proxied = args.proxied; + doc.source = 'manual'; + doc.createdAt = now; + doc.updatedAt = now; + doc.createdBy = args.createdBy; + + if (domain.source === 'provider') { + // Push to provider first; only persist locally on success + if (!domain.providerId) { + return { success: false, message: 'Provider domain has no providerId' }; + } + const client = await this.getProviderClientById(domain.providerId); + if (!client) return { success: false, message: 'Provider client unavailable' }; + try { + const created = await client.createRecord(domain.name, { + name: doc.name, + type: doc.type, + value: doc.value, + ttl: doc.ttl, + proxied: doc.proxied, + }); + doc.providerRecordId = created.providerRecordId; + doc.source = 'synced'; + } catch (err: unknown) { + return { success: false, message: `Provider rejected record: ${(err as Error).message}` }; + } + } else { + // Manual / authoritative — register with embedded DnsServer immediately + this.registerRecordWithDnsServer(doc); + } + + await doc.save(); + return { success: true, id: doc.id }; + } + + public async updateRecord(args: { + id: string; + name?: string; + value?: string; + ttl?: number; + proxied?: boolean; + }): Promise<{ success: boolean; message?: string }> { + const doc = await DnsRecordDoc.findById(args.id); + if (!doc) return { success: false, message: 'Record not found' }; + const domain = await DomainDoc.findById(doc.domainId); + if (!domain) return { success: false, message: 'Parent domain not found' }; + + if (args.name !== undefined) doc.name = args.name.toLowerCase(); + if (args.value !== undefined) doc.value = args.value; + if (args.ttl !== undefined) doc.ttl = args.ttl; + if (args.proxied !== undefined) doc.proxied = args.proxied; + doc.updatedAt = Date.now(); + + if (domain.source === 'provider') { + if (!domain.providerId || !doc.providerRecordId) { + return { success: false, message: 'Provider record metadata missing' }; + } + const client = await this.getProviderClientById(domain.providerId); + if (!client) return { success: false, message: 'Provider client unavailable' }; + try { + await client.updateRecord(domain.name, doc.providerRecordId, { + name: doc.name, + type: doc.type, + value: doc.value, + ttl: doc.ttl, + proxied: doc.proxied, + }); + } catch (err: unknown) { + 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 + this.registerRecordWithDnsServer(doc); + } + + await doc.save(); + return { success: true }; + } + + public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> { + const doc = await DnsRecordDoc.findById(id); + if (!doc) return { success: false, message: 'Record not found' }; + const domain = await DomainDoc.findById(doc.domainId); + if (!domain) return { success: false, message: 'Parent domain not found' }; + + if (domain.source === 'provider') { + if (domain.providerId && doc.providerRecordId) { + const client = await this.getProviderClientById(domain.providerId); + if (client) { + try { + await client.deleteRecord(domain.name, doc.providerRecordId); + } catch (err: unknown) { + return { success: false, message: `Provider rejected delete: ${(err as Error).message}` }; + } + } + } + } + // For manual 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. + + await doc.delete(); + return { success: true }; + } + + // ========================================================================== + // Internal helpers + // ========================================================================== + + private async createSyncedRecord( + domainId: string, + pr: IProviderRecord, + createdBy: string, + ): Promise { + const now = Date.now(); + const doc = new DnsRecordDoc(); + doc.id = plugins.uuid.v4(); + doc.domainId = domainId; + doc.name = pr.name.toLowerCase(); + doc.type = pr.type; + doc.value = pr.value; + doc.ttl = pr.ttl; + if (pr.proxied !== undefined) doc.proxied = pr.proxied; + doc.source = 'synced'; + doc.providerRecordId = pr.providerRecordId; + doc.createdAt = now; + doc.updatedAt = now; + doc.createdBy = createdBy; + await doc.save(); + } + + /** + * Convert a DnsProviderDoc to its public, secret-stripped representation + * for the OpsServer API. + */ + public toPublicProvider(doc: DnsProviderDoc): IDnsProviderPublic { + return { + id: doc.id, + name: doc.name, + type: doc.type, + status: doc.status, + lastTestedAt: doc.lastTestedAt, + lastError: doc.lastError, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + createdBy: doc.createdBy, + hasCredentials: !!doc.credentials, + }; + } + + /** + * Convert a DomainDoc to its plain interface representation. + */ + public toPublicDomain(doc: DomainDoc): { + id: string; + name: string; + source: 'manual' | 'provider'; + providerId?: string; + authoritative: boolean; + nameservers?: string[]; + externalZoneId?: string; + lastSyncedAt?: number; + description?: string; + createdAt: number; + updatedAt: number; + createdBy: string; + } { + return { + id: doc.id, + name: doc.name, + source: doc.source, + providerId: doc.providerId, + authoritative: doc.authoritative, + nameservers: doc.nameservers, + externalZoneId: doc.externalZoneId, + lastSyncedAt: doc.lastSyncedAt, + description: doc.description, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + createdBy: doc.createdBy, + }; + } + + /** + * Convert a DnsRecordDoc to its plain interface representation. + */ + public toPublicRecord(doc: DnsRecordDoc): { + id: string; + domainId: string; + name: string; + type: TDnsRecordType; + value: string; + ttl: number; + proxied?: boolean; + source: TDnsRecordSource; + providerRecordId?: string; + createdAt: number; + updatedAt: number; + createdBy: string; + } { + return { + id: doc.id, + domainId: doc.domainId, + name: doc.name, + type: doc.type, + value: doc.value, + ttl: doc.ttl, + proxied: doc.proxied, + source: doc.source, + providerRecordId: doc.providerRecordId, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + createdBy: doc.createdBy, + }; + } +} diff --git a/ts/dns/providers/cloudflare.provider.ts b/ts/dns/providers/cloudflare.provider.ts new file mode 100644 index 0000000..7f91205 --- /dev/null +++ b/ts/dns/providers/cloudflare.provider.ts @@ -0,0 +1,139 @@ +import * as plugins from '../../plugins.js'; +import { logger } from '../../logger.js'; +import type { + IDnsProviderClient, + IConnectionTestResult, + IProviderRecord, + IProviderRecordInput, +} from './interfaces.js'; +import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js'; +import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js'; + +/** + * Cloudflare implementation of IDnsProviderClient. + * + * Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by + * an internal record id, which we surface as `providerRecordId` so the rest + * of the system can issue updates and deletes without ambiguity (Cloudflare + * can have multiple records of the same name+type). + */ +export class CloudflareDnsProvider implements IDnsProviderClient { + private cfAccount: plugins.cloudflare.CloudflareAccount; + + constructor(apiToken: string) { + if (!apiToken) { + throw new Error('CloudflareDnsProvider: apiToken is required'); + } + this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken); + } + + /** + * Returns the underlying CloudflareAccount — used by ACME DNS-01 + * to wrap into a smartacme Dns01Handler. + */ + public getCloudflareAccount(): plugins.cloudflare.CloudflareAccount { + return this.cfAccount; + } + + public async testConnection(): Promise { + try { + // Listing zones is the lightest-weight call that proves the token works. + await this.cfAccount.zoneManager.listZones(); + return { ok: true }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`); + return { ok: false, error: message }; + } + } + + public async listDomains(): Promise { + const zones = await this.cfAccount.zoneManager.listZones(); + return zones.map((zone) => ({ + name: zone.name, + externalId: zone.id, + nameservers: zone.name_servers ?? [], + })); + } + + public async listRecords(domain: string): Promise { + const records = await this.cfAccount.recordManager.listRecords(domain); + return records + .filter((r) => this.isSupportedType(r.type)) + .map((r) => ({ + providerRecordId: r.id, + name: r.name, + type: r.type as TDnsRecordType, + value: r.content, + ttl: r.ttl, + proxied: r.proxied, + })); + } + + public async createRecord( + domain: string, + record: IProviderRecordInput, + ): Promise { + const zoneId = await this.cfAccount.zoneManager.getZoneId(domain); + const apiRecord: any = { + zone_id: zoneId, + type: record.type, + name: record.name, + content: record.value, + ttl: record.ttl ?? 1, // 1 = automatic + }; + if (record.proxied !== undefined) { + apiRecord.proxied = record.proxied; + } + const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord); + return { + providerRecordId: created.id, + name: created.name, + type: created.type as TDnsRecordType, + value: created.content, + ttl: created.ttl, + proxied: created.proxied, + }; + } + + public async updateRecord( + domain: string, + providerRecordId: string, + record: IProviderRecordInput, + ): Promise { + const zoneId = await this.cfAccount.zoneManager.getZoneId(domain); + const apiRecord: any = { + zone_id: zoneId, + type: record.type, + name: record.name, + content: record.value, + ttl: record.ttl ?? 1, + }; + if (record.proxied !== undefined) { + apiRecord.proxied = record.proxied; + } + const updated = await (this.cfAccount as any).apiAccount.dns.records.edit( + providerRecordId, + apiRecord, + ); + return { + providerRecordId: updated.id, + name: updated.name, + type: updated.type as TDnsRecordType, + value: updated.content, + ttl: updated.ttl, + proxied: updated.proxied, + }; + } + + public async deleteRecord(domain: string, providerRecordId: string): Promise { + const zoneId = await this.cfAccount.zoneManager.getZoneId(domain); + await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, { + zone_id: zoneId, + }); + } + + private isSupportedType(type: string): boolean { + return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type); + } +} diff --git a/ts/dns/providers/factory.ts b/ts/dns/providers/factory.ts new file mode 100644 index 0000000..97fecef --- /dev/null +++ b/ts/dns/providers/factory.ts @@ -0,0 +1,31 @@ +import type { IDnsProviderClient } from './interfaces.js'; +import type { + TDnsProviderType, + TDnsProviderCredentials, +} from '../../../ts_interfaces/data/dns-provider.js'; +import { CloudflareDnsProvider } from './cloudflare.provider.js'; + +/** + * Instantiate a runtime DNS provider client from a stored DnsProviderDoc. + * + * @throws if the provider type is not supported. + */ +export function createDnsProvider( + type: TDnsProviderType, + credentials: TDnsProviderCredentials, +): IDnsProviderClient { + switch (type) { + case 'cloudflare': { + if (credentials.type !== 'cloudflare') { + throw new Error( + `createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`, + ); + } + return new CloudflareDnsProvider(credentials.apiToken); + } + default: { + const _exhaustive: never = type; + throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`); + } + } +} diff --git a/ts/dns/providers/index.ts b/ts/dns/providers/index.ts new file mode 100644 index 0000000..70496ef --- /dev/null +++ b/ts/dns/providers/index.ts @@ -0,0 +1,3 @@ +export * from './interfaces.js'; +export * from './cloudflare.provider.js'; +export * from './factory.js'; diff --git a/ts/dns/providers/interfaces.ts b/ts/dns/providers/interfaces.ts new file mode 100644 index 0000000..14c86cd --- /dev/null +++ b/ts/dns/providers/interfaces.ts @@ -0,0 +1,67 @@ +import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js'; +import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js'; + +/** + * A DNS record as seen at a provider's API. The `providerRecordId` field + * is the provider's internal identifier, used for subsequent updates and + * deletes (since providers can have multiple records of the same name+type). + */ +export interface IProviderRecord { + providerRecordId: string; + name: string; + type: TDnsRecordType; + value: string; + ttl: number; + proxied?: boolean; +} + +/** + * Input shape for creating / updating a DNS record at a provider. + */ +export interface IProviderRecordInput { + name: string; + type: TDnsRecordType; + value: string; + ttl?: number; + proxied?: boolean; +} + +/** + * Outcome of a connection test against a provider's API. + */ +export interface IConnectionTestResult { + ok: boolean; + error?: string; +} + +/** + * Pluggable DNS provider client interface. One implementation per provider type + * (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are + * instantiated by `createDnsProvider()` in factory.ts. + * + * NOT a smartdata interface — this is the *runtime* client. The persisted + * representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts). + */ +export interface IDnsProviderClient { + /** Lightweight check that credentials are valid and the API is reachable. */ + testConnection(): Promise; + + /** List all DNS zones visible to this provider account. */ + listDomains(): Promise; + + /** List all DNS records for a zone (FQDN). */ + listRecords(domain: string): Promise; + + /** Create a new DNS record at the provider; returns the created record (with id). */ + createRecord(domain: string, record: IProviderRecordInput): Promise; + + /** Update an existing record by provider id; returns the updated record. */ + updateRecord( + domain: string, + providerRecordId: string, + record: IProviderRecordInput, + ): Promise; + + /** Delete a record by provider id. */ + deleteRecord(domain: string, providerRecordId: string): Promise; +} diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 6e10d8a..9c62af6 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -33,6 +33,9 @@ export class OpsServer { private targetProfileHandler!: handlers.TargetProfileHandler; private networkTargetHandler!: handlers.NetworkTargetHandler; private usersHandler!: handlers.UsersHandler; + private dnsProviderHandler!: handlers.DnsProviderHandler; + private domainHandler!: handlers.DomainHandler; + private dnsRecordHandler!: handlers.DnsRecordHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -96,6 +99,9 @@ export class OpsServer { this.targetProfileHandler = new handlers.TargetProfileHandler(this); this.networkTargetHandler = new handlers.NetworkTargetHandler(this); this.usersHandler = new handlers.UsersHandler(this); + this.dnsProviderHandler = new handlers.DnsProviderHandler(this); + this.domainHandler = new handlers.DomainHandler(this); + this.dnsRecordHandler = new handlers.DnsRecordHandler(this); console.log('✅ OpsServer TypedRequest handlers initialized'); } diff --git a/ts/opsserver/handlers/config.handler.ts b/ts/opsserver/handlers/config.handler.ts index 19e7fe6..363c38f 100644 --- a/ts/opsserver/handlers/config.handler.ts +++ b/ts/opsserver/handlers/config.handler.ts @@ -123,6 +123,15 @@ export class ConfigHandler { ttl: r.ttl, })); + // dnsChallenge: true when at least one DnsProviderDoc exists in the DB + // (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field). + let dnsChallengeEnabled = false; + try { + dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false; + } catch { + dnsChallengeEnabled = false; + } + const dns: interfaces.requests.IConfigData['dns'] = { enabled: !!dcRouter.dnsServer, port: 53, @@ -130,7 +139,7 @@ export class ConfigHandler { scopes: opts.dnsScopes || [], recordCount: dnsRecords.length, records: dnsRecords, - dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey, + dnsChallenge: dnsChallengeEnabled, }; // --- TLS --- diff --git a/ts/opsserver/handlers/dns-provider.handler.ts b/ts/opsserver/handlers/dns-provider.handler.ts new file mode 100644 index 0000000..f0ff5a0 --- /dev/null +++ b/ts/opsserver/handlers/dns-provider.handler.ts @@ -0,0 +1,159 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +/** + * CRUD + connection-test handlers for DnsProviderDoc. + * + * Auth: same dual-mode pattern as TargetProfileHandler — admin JWT or + * API token with the appropriate `dns-providers:read|write` scope. + */ +export class DnsProviderHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private async requireAuth( + request: { identity?: interfaces.data.IIdentity; apiToken?: string }, + requiredScope?: interfaces.data.TApiTokenScope, + ): Promise { + if (request.identity?.jwt) { + try { + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ + identity: request.identity, + }); + if (isAdmin) return request.identity.userId; + } catch { /* fall through */ } + } + + if (request.apiToken) { + const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (tokenManager) { + const token = await tokenManager.validateToken(request.apiToken); + if (token) { + if (!requiredScope || tokenManager.hasScope(token, requiredScope)) { + return token.createdBy; + } + throw new plugins.typedrequest.TypedResponseError('insufficient scope'); + } + } + } + + throw new plugins.typedrequest.TypedResponseError('unauthorized'); + } + + private registerHandlers(): void { + // Get all providers + 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() }; + }, + ), + ); + + // Get single provider + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDnsProvider', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-providers:read'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { provider: null }; + return { provider: await dnsManager.getProvider(dataArg.id) }; + }, + ), + ); + + // Create provider + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createDnsProvider', + async (dataArg) => { + const userId = await this.requireAuth(dataArg, 'dns-providers:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) { + return { success: false, message: 'DnsManager not initialized (DB disabled?)' }; + } + const id = await dnsManager.createProvider({ + name: dataArg.name, + type: dataArg.type, + credentials: dataArg.credentials, + createdBy: userId, + }); + return { success: true, id }; + }, + ), + ); + + // Update provider + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateDnsProvider', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-providers:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + const ok = await dnsManager.updateProvider(dataArg.id, { + name: dataArg.name, + credentials: dataArg.credentials, + }); + return ok ? { success: true } : { success: false, message: 'Provider not found' }; + }, + ), + ); + + // Delete provider + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteDnsProvider', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-providers:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false); + }, + ), + ); + + // Test provider connection + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'testDnsProvider', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-providers:read'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) { + return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() }; + } + return await dnsManager.testProvider(dataArg.id); + }, + ), + ); + + // List domains visible to a provider's account (without importing them) + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'listProviderDomains', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-providers:read'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + try { + const domains = await dnsManager.listProviderDomains(dataArg.providerId); + return { success: true, domains }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/dns-record.handler.ts b/ts/opsserver/handlers/dns-record.handler.ts new file mode 100644 index 0000000..96c8c6b --- /dev/null +++ b/ts/opsserver/handlers/dns-record.handler.ts @@ -0,0 +1,127 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +/** + * CRUD handlers for DnsRecordDoc. + */ +export class DnsRecordHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private async requireAuth( + request: { identity?: interfaces.data.IIdentity; apiToken?: string }, + requiredScope?: interfaces.data.TApiTokenScope, + ): Promise { + if (request.identity?.jwt) { + try { + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ + identity: request.identity, + }); + if (isAdmin) return request.identity.userId; + } catch { /* fall through */ } + } + + if (request.apiToken) { + const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (tokenManager) { + const token = await tokenManager.validateToken(request.apiToken); + if (token) { + if (!requiredScope || tokenManager.hasScope(token, requiredScope)) { + return token.createdBy; + } + throw new plugins.typedrequest.TypedResponseError('insufficient scope'); + } + } + } + + throw new plugins.typedrequest.TypedResponseError('unauthorized'); + } + + private registerHandlers(): void { + // Get records by domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDnsRecords', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-records:read'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { records: [] }; + const docs = await dnsManager.listRecordsForDomain(dataArg.domainId); + return { records: docs.map((d) => dnsManager.toPublicRecord(d)) }; + }, + ), + ); + + // Get single record + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDnsRecord', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-records:read'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { record: null }; + const doc = await dnsManager.getRecord(dataArg.id); + return { record: doc ? dnsManager.toPublicRecord(doc) : null }; + }, + ), + ); + + // Create record + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createDnsRecord', + async (dataArg) => { + const userId = await this.requireAuth(dataArg, 'dns-records:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + return await dnsManager.createRecord({ + domainId: dataArg.domainId, + name: dataArg.name, + type: dataArg.type, + value: dataArg.value, + ttl: dataArg.ttl, + proxied: dataArg.proxied, + createdBy: userId, + }); + }, + ), + ); + + // Update record + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateDnsRecord', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-records:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + return await dnsManager.updateRecord({ + id: dataArg.id, + name: dataArg.name, + value: dataArg.value, + ttl: dataArg.ttl, + proxied: dataArg.proxied, + }); + }, + ), + ); + + // Delete record + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteDnsRecord', + async (dataArg) => { + await this.requireAuth(dataArg, 'dns-records:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + return await dnsManager.deleteRecord(dataArg.id); + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/domain.handler.ts b/ts/opsserver/handlers/domain.handler.ts new file mode 100644 index 0000000..a3f1e07 --- /dev/null +++ b/ts/opsserver/handlers/domain.handler.ts @@ -0,0 +1,161 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +/** + * CRUD handlers for DomainDoc. + */ +export class DomainHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private async requireAuth( + request: { identity?: interfaces.data.IIdentity; apiToken?: string }, + requiredScope?: interfaces.data.TApiTokenScope, + ): Promise { + if (request.identity?.jwt) { + try { + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ + identity: request.identity, + }); + if (isAdmin) return request.identity.userId; + } catch { /* fall through */ } + } + + if (request.apiToken) { + const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (tokenManager) { + const token = await tokenManager.validateToken(request.apiToken); + if (token) { + if (!requiredScope || tokenManager.hasScope(token, requiredScope)) { + return token.createdBy; + } + throw new plugins.typedrequest.TypedResponseError('insufficient scope'); + } + } + } + + throw new plugins.typedrequest.TypedResponseError('unauthorized'); + } + + private registerHandlers(): void { + // Get all domains + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDomains', + async (dataArg) => { + await this.requireAuth(dataArg, 'domains:read'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { domains: [] }; + const docs = await dnsManager.listDomains(); + return { domains: docs.map((d) => dnsManager.toPublicDomain(d)) }; + }, + ), + ); + + // Get single domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'domains:read'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { domain: null }; + const doc = await dnsManager.getDomain(dataArg.id); + return { domain: doc ? dnsManager.toPublicDomain(doc) : null }; + }, + ), + ); + + // Create manual domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createDomain', + async (dataArg) => { + const userId = await this.requireAuth(dataArg, 'domains:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + try { + const id = await dnsManager.createManualDomain({ + name: dataArg.name, + description: dataArg.description, + createdBy: userId, + }); + return { success: true, id }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Import domains from a provider + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'importDomain', + async (dataArg) => { + const userId = await this.requireAuth(dataArg, 'domains:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + try { + const importedIds = await dnsManager.importDomainsFromProvider({ + providerId: dataArg.providerId, + domainNames: dataArg.domainNames, + createdBy: userId, + }); + return { success: true, importedIds }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Update domain metadata + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'domains:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + const ok = await dnsManager.updateDomain(dataArg.id, { + description: dataArg.description, + }); + return ok ? { success: true } : { success: false, message: 'Domain not found' }; + }, + ), + ); + + // Delete domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'domains:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + const ok = await dnsManager.deleteDomain(dataArg.id); + return ok ? { success: true } : { success: false, message: 'Domain not found' }; + }, + ), + ); + + // Force-resync provider domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'syncDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'domains:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + return await dnsManager.syncDomain(dataArg.id); + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index 416b9f1..2af6f96 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -13,4 +13,7 @@ export * from './vpn.handler.js'; export * from './source-profile.handler.js'; export * from './target-profile.handler.js'; export * from './network-target.handler.js'; -export * from './users.handler.js'; \ No newline at end of file +export * from './users.handler.js'; +export * from './dns-provider.handler.js'; +export * from './domain.handler.js'; +export * from './dns-record.handler.js'; \ No newline at end of file diff --git a/ts_interfaces/data/dns-provider.ts b/ts_interfaces/data/dns-provider.ts new file mode 100644 index 0000000..8396bed --- /dev/null +++ b/ts_interfaces/data/dns-provider.ts @@ -0,0 +1,73 @@ +/** + * 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/. + */ +export type TDnsProviderType = 'cloudflare'; + +/** + * Status of the last connection test against a provider. + */ +export type TDnsProviderStatus = 'untested' | 'ok' | 'error'; + +/** + * Cloudflare-specific credential shape. + */ +export interface ICloudflareCredentials { + apiToken: string; +} + +/** + * Discriminated union of all supported provider credential shapes. + * Persisted opaquely on `IDnsProvider.credentials`. + */ +export type TDnsProviderCredentials = + | ({ type: 'cloudflare' } & ICloudflareCredentials); + +/** + * A registered DNS provider account. Holds the credentials needed to + * call the provider's API and a snapshot of its last health check. + */ +export interface IDnsProvider { + id: string; + name: string; + type: TDnsProviderType; + /** Opaque credentials object — shape depends on `type`. */ + credentials: TDnsProviderCredentials; + status: TDnsProviderStatus; + lastTestedAt?: number; + lastError?: string; + createdAt: number; + updatedAt: number; + createdBy: string; +} + +/** + * A redacted view of IDnsProvider safe to send to the UI / over the wire. + * Strips secret fields from `credentials` while preserving the rest. + */ +export interface IDnsProviderPublic { + id: string; + name: string; + type: TDnsProviderType; + status: TDnsProviderStatus; + lastTestedAt?: number; + lastError?: string; + createdAt: number; + updatedAt: number; + createdBy: string; + /** Whether credentials are configured (true after creation). Never the secret itself. */ + hasCredentials: boolean; +} + +/** + * A domain reported by a provider's API (not yet imported into dcrouter). + */ +export interface IProviderDomainListing { + /** FQDN of the zone (e.g. 'example.com'). */ + name: string; + /** Provider's internal zone identifier (zone_id for Cloudflare). */ + externalId: string; + /** Authoritative nameservers reported by the provider. */ + nameservers: string[]; +} diff --git a/ts_interfaces/data/dns-record.ts b/ts_interfaces/data/dns-record.ts new file mode 100644 index 0000000..304cce3 --- /dev/null +++ b/ts_interfaces/data/dns-record.ts @@ -0,0 +1,42 @@ +/** + * Supported DNS record types. + */ +export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA' | 'CAA'; + +/** + * Where a DNS record came from. + * + * - 'manual' → created in the dcrouter UI / API + * - 'synced' → pulled from a provider during a sync operation + */ +export type TDnsRecordSource = 'manual' | '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). + */ +export interface IDnsRecord { + id: string; + /** ID of the parent IDomain. */ + domainId: string; + /** Fully qualified record name (e.g. 'www.example.com'). */ + name: string; + type: TDnsRecordType; + /** + * Record value as a string. For MX records, formatted as + * ` ` (e.g. `10 mail.example.com`). + */ + value: string; + /** TTL in seconds. */ + ttl: number; + /** Cloudflare-specific: whether the record is proxied through Cloudflare. */ + proxied?: boolean; + source: TDnsRecordSource; + /** Provider's internal record id (for updates/deletes). Only set for provider records. */ + providerRecordId?: string; + createdAt: number; + updatedAt: number; + createdBy: string; +} diff --git a/ts_interfaces/data/domain.ts b/ts_interfaces/data/domain.ts new file mode 100644 index 0000000..927c582 --- /dev/null +++ b/ts_interfaces/data/domain.ts @@ -0,0 +1,35 @@ +/** + * 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. + * - '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. + */ +export type TDomainSource = 'manual' | 'provider'; + +/** + * A domain under management by dcrouter. + */ +export interface IDomain { + id: string; + /** Fully qualified domain name (e.g. 'example.com'). */ + name: string; + 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'). */ + authoritative: boolean; + /** Authoritative nameservers (display only — populated from provider for imported domains). */ + nameservers?: string[]; + /** Provider's internal zone identifier — only set when source === 'provider'. */ + externalZoneId?: string; + /** Last time records were synced from the provider — only set when source === 'provider'. */ + lastSyncedAt?: number; + description?: string; + createdAt: number; + updatedAt: number; + createdBy: string; +} diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 9bab981..447246a 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -3,4 +3,7 @@ export * from './stats.js'; export * from './remoteingress.js'; export * from './route-management.js'; export * from './target-profile.js'; -export * from './vpn.js'; \ No newline at end of file +export * from './vpn.js'; +export * from './dns-provider.js'; +export * from './domain.js'; +export * from './dns-record.js'; \ No newline at end of file diff --git a/ts_interfaces/data/route-management.ts b/ts_interfaces/data/route-management.ts index d49bacc..05fec24 100644 --- a/ts_interfaces/data/route-management.ts +++ b/ts_interfaces/data/route-management.ts @@ -14,7 +14,10 @@ export type TApiTokenScope = | 'tokens:read' | 'tokens:manage' | 'source-profiles:read' | 'source-profiles:write' | 'target-profiles:read' | 'target-profiles:write' - | 'targets:read' | 'targets:write'; + | 'targets:read' | 'targets:write' + | 'dns-providers:read' | 'dns-providers:write' + | 'domains:read' | 'domains:write' + | 'dns-records:read' | 'dns-records:write'; // ============================================================================ // Source Profile Types (source-side: who can access) diff --git a/ts_interfaces/requests/dns-providers.ts b/ts_interfaces/requests/dns-providers.ts new file mode 100644 index 0000000..ca24f6e --- /dev/null +++ b/ts_interfaces/requests/dns-providers.ts @@ -0,0 +1,154 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { + IDnsProviderPublic, + IProviderDomainListing, + TDnsProviderType, + TDnsProviderCredentials, +} from '../data/dns-provider.js'; + +// ============================================================================ +// DNS Provider Endpoints +// ============================================================================ + +/** + * Get all DNS providers (public view, no secrets). + */ +export interface IReq_GetDnsProviders extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetDnsProviders +> { + method: 'getDnsProviders'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + providers: IDnsProviderPublic[]; + }; +} + +/** + * Get a single DNS provider by id. + */ +export interface IReq_GetDnsProvider extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetDnsProvider +> { + method: 'getDnsProvider'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + provider: IDnsProviderPublic | null; + }; +} + +/** + * Create a new DNS provider. + */ +export interface IReq_CreateDnsProvider extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateDnsProvider +> { + method: 'createDnsProvider'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + name: string; + type: TDnsProviderType; + credentials: TDnsProviderCredentials; + }; + response: { + success: boolean; + id?: string; + message?: string; + }; +} + +/** + * Update a DNS provider. Only supplied fields are updated. + * Pass `credentials` to rotate the secret. + */ +export interface IReq_UpdateDnsProvider extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateDnsProvider +> { + method: 'updateDnsProvider'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + name?: string; + credentials?: TDnsProviderCredentials; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Delete a DNS provider. Fails if any IDomain still references it + * unless `force: true` is set. + */ +export interface IReq_DeleteDnsProvider extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteDnsProvider +> { + method: 'deleteDnsProvider'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + force?: boolean; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Test the connection to a DNS provider. Used both for newly-saved + * providers and on demand from the UI. + */ +export interface IReq_TestDnsProvider extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_TestDnsProvider +> { + method: 'testDnsProvider'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + ok: boolean; + error?: string; + testedAt: number; + }; +} + +/** + * List the domains visible to a DNS provider's API account. + * Used when importing — does NOT persist anything. + */ +export interface IReq_ListProviderDomains extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ListProviderDomains +> { + method: 'listProviderDomains'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + providerId: string; + }; + response: { + success: boolean; + domains?: IProviderDomainListing[]; + message?: string; + }; +} diff --git a/ts_interfaces/requests/dns-records.ts b/ts_interfaces/requests/dns-records.ts new file mode 100644 index 0000000..704edbd --- /dev/null +++ b/ts_interfaces/requests/dns-records.ts @@ -0,0 +1,113 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { IDnsRecord, TDnsRecordType } from '../data/dns-record.js'; + +// ============================================================================ +// DNS Record Endpoints +// ============================================================================ + +/** + * Get all DNS records for a domain. + */ +export interface IReq_GetDnsRecords extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetDnsRecords +> { + method: 'getDnsRecords'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + domainId: string; + }; + response: { + records: IDnsRecord[]; + }; +} + +/** + * Get a single DNS record by id. + */ +export interface IReq_GetDnsRecord extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetDnsRecord +> { + method: 'getDnsRecord'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + record: IDnsRecord | null; + }; +} + +/** + * Create a new DNS record. + * + * For manual 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< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateDnsRecord +> { + method: 'createDnsRecord'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + domainId: string; + name: string; + type: TDnsRecordType; + value: string; + ttl?: number; + proxied?: boolean; + }; + response: { + success: boolean; + id?: string; + message?: string; + }; +} + +/** + * Update a DNS record. + */ +export interface IReq_UpdateDnsRecord extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateDnsRecord +> { + method: 'updateDnsRecord'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + name?: string; + value?: string; + ttl?: number; + proxied?: boolean; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Delete a DNS record. + */ +export interface IReq_DeleteDnsRecord extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteDnsRecord +> { + method: 'deleteDnsRecord'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} diff --git a/ts_interfaces/requests/domains.ts b/ts_interfaces/requests/domains.ts new file mode 100644 index 0000000..3597953 --- /dev/null +++ b/ts_interfaces/requests/domains.ts @@ -0,0 +1,150 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { IDomain } from '../data/domain.js'; + +// ============================================================================ +// Domain Endpoints +// ============================================================================ + +/** + * Get all domains under management. + */ +export interface IReq_GetDomains extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetDomains +> { + method: 'getDomains'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + domains: IDomain[]; + }; +} + +/** + * Get a single domain by id. + */ +export interface IReq_GetDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetDomain +> { + method: 'getDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + domain: IDomain | null; + }; +} + +/** + * Create a manual (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, + IReq_CreateDomain +> { + method: 'createDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + name: string; + description?: string; + }; + response: { + success: boolean; + id?: string; + message?: string; + }; +} + +/** + * Import one or more domains from a DNS provider. For each imported + * domain, records are pulled from the provider into DnsRecordDoc. + */ +export interface IReq_ImportDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ImportDomain +> { + method: 'importDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + providerId: string; + /** FQDN(s) of the zone(s) to import — must be visible to the provider account. */ + domainNames: string[]; + }; + response: { + success: boolean; + importedIds?: string[]; + message?: string; + }; +} + +/** + * Update a domain's metadata. Cannot change source / providerId once set. + */ +export interface IReq_UpdateDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateDomain +> { + method: 'updateDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + description?: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Delete a domain and all of its DNS records. + * For provider-managed domains, this only removes dcrouter's local record — + * it does NOT delete the zone at the provider. + */ +export interface IReq_DeleteDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteDomain +> { + method: 'deleteDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Force-resync a provider-managed domain: re-pulls all records from the + * provider API, replacing the cached DnsRecordDocs. + * No-op for manual domains. + */ +export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_SyncDomain +> { + method: 'syncDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + success: boolean; + recordCount?: number; + message?: string; + }; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index c8affa2..d66e2ff 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -13,4 +13,7 @@ export * from './vpn.js'; export * from './source-profiles.js'; export * from './target-profiles.js'; export * from './network-targets.js'; -export * from './users.js'; \ No newline at end of file +export * from './users.js'; +export * from './dns-providers.js'; +export * from './domains.js'; +export * from './dns-records.js'; \ No newline at end of file diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 5ad42f6..1b4d364 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.5.0', + version: '13.6.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 49917a5..9578766 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -117,7 +117,7 @@ export const configStatePart = await appState.getStatePart( // Determine initial view from URL path const getInitialView = (): string => { const path = typeof window !== 'undefined' ? window.location.pathname : '/'; - const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'certificates']; + const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'domains']; const segments = path.split('/').filter(Boolean); const view = segments[0]; return validViews.includes(view) ? view : 'overview'; @@ -465,8 +465,9 @@ export const setActiveViewAction = uiStatePart.createAction(async (state }, 100); } - // If switching to certificates view, ensure we fetch certificate data - if (viewName === 'certificates' && currentState.activeView !== 'certificates') { + // If switching to the Domains group, ensure we fetch certificate data + // (Certificates is a subview of Domains). + if (viewName === 'domains' && currentState.activeView !== 'domains') { setTimeout(() => { certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); }, 100); @@ -1555,6 +1556,403 @@ export const deleteTargetAction = profilesTargetsStatePart.createAction<{ } }); +// ============================================================================ +// Domains State (DNS providers + domains + records) +// ============================================================================ + +export interface IDomainsState { + providers: interfaces.data.IDnsProviderPublic[]; + domains: interfaces.data.IDomain[]; + records: interfaces.data.IDnsRecord[]; + /** id of the currently-selected domain in the DNS records subview. */ + selectedDomainId: string | null; + isLoading: boolean; + error: string | null; + lastUpdated: number; +} + +export const domainsStatePart = await appState.getStatePart( + 'domains', + { + providers: [], + domains: [], + records: [], + selectedDomainId: null, + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft', +); + +export const fetchDomainsAndProvidersAction = domainsStatePart.createAction( + async (statePartArg): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const providersRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetDnsProviders + >('/typedrequest', 'getDnsProviders'); + const domainsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetDomains + >('/typedrequest', 'getDomains'); + + const [providersResponse, domainsResponse] = await Promise.all([ + providersRequest.fire({ identity: context.identity }), + domainsRequest.fire({ identity: context.identity }), + ]); + + return { + ...currentState, + providers: providersResponse.providers, + domains: domainsResponse.domains, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error: unknown) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch domains/providers', + }; + } + }, +); + +export const fetchDnsRecordsForDomainAction = domainsStatePart.createAction<{ domainId: string }>( + async (statePartArg, dataArg): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetDnsRecords + >('/typedrequest', 'getDnsRecords'); + const response = await request.fire({ + identity: context.identity, + domainId: dataArg.domainId, + }); + return { + ...currentState, + records: response.records, + selectedDomainId: dataArg.domainId, + error: null, + }; + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to fetch DNS records', + }; + } + }, +); + +export const createDnsProviderAction = domainsStatePart.createAction<{ + name: string; + type: interfaces.data.TDnsProviderType; + credentials: interfaces.data.TDnsProviderCredentials; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateDnsProvider + >('/typedrequest', 'createDnsProvider'); + const response = await request.fire({ + identity: context.identity!, + name: dataArg.name, + type: dataArg.type, + credentials: dataArg.credentials, + }); + if (!response.success) { + return { + ...statePartArg.getState()!, + error: response.message || 'Failed to create provider', + }; + } + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to create provider', + }; + } +}); + +export const updateDnsProviderAction = domainsStatePart.createAction<{ + id: string; + name?: string; + credentials?: interfaces.data.TDnsProviderCredentials; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateDnsProvider + >('/typedrequest', 'updateDnsProvider'); + const response = await request.fire({ + identity: context.identity!, + id: dataArg.id, + name: dataArg.name, + credentials: dataArg.credentials, + }); + if (!response.success) { + return { + ...statePartArg.getState()!, + error: response.message || 'Failed to update provider', + }; + } + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to update provider', + }; + } +}); + +export const deleteDnsProviderAction = domainsStatePart.createAction<{ id: string; force?: boolean }>( + async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteDnsProvider + >('/typedrequest', 'deleteDnsProvider'); + const response = await request.fire({ + identity: context.identity!, + id: dataArg.id, + force: dataArg.force, + }); + if (!response.success) { + return { + ...statePartArg.getState()!, + error: response.message || 'Failed to delete provider', + }; + } + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to delete provider', + }; + } + }, +); + +export const testDnsProviderAction = domainsStatePart.createAction<{ id: string }>( + async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_TestDnsProvider + >('/typedrequest', 'testDnsProvider'); + await request.fire({ identity: context.identity!, id: dataArg.id }); + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to test provider', + }; + } + }, +); + +/** One-shot fetch for the import-domain modal. Does NOT modify state. */ +export async function fetchProviderDomains( + providerId: string, +): Promise<{ success: boolean; domains?: interfaces.data.IProviderDomainListing[]; message?: string }> { + const context = getActionContext(); + if (!context.identity) return { success: false, message: 'Not authenticated' }; + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ListProviderDomains + >('/typedrequest', 'listProviderDomains'); + return await request.fire({ identity: context.identity, providerId }); +} + +export const createManualDomainAction = domainsStatePart.createAction<{ + name: string; + description?: string; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateDomain + >('/typedrequest', 'createDomain'); + const response = await request.fire({ + identity: context.identity!, + name: dataArg.name, + description: dataArg.description, + }); + if (!response.success) { + return { ...statePartArg.getState()!, error: response.message || 'Failed to create domain' }; + } + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to create domain', + }; + } +}); + +export const importDomainsFromProviderAction = domainsStatePart.createAction<{ + providerId: string; + domainNames: string[]; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ImportDomain + >('/typedrequest', 'importDomain'); + const response = await request.fire({ + identity: context.identity!, + providerId: dataArg.providerId, + domainNames: dataArg.domainNames, + }); + if (!response.success) { + return { ...statePartArg.getState()!, error: response.message || 'Failed to import domains' }; + } + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to import domains', + }; + } +}); + +export const deleteDomainAction = domainsStatePart.createAction<{ id: string }>( + async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteDomain + >('/typedrequest', 'deleteDomain'); + const response = await request.fire({ identity: context.identity!, id: dataArg.id }); + if (!response.success) { + return { ...statePartArg.getState()!, error: response.message || 'Failed to delete domain' }; + } + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to delete domain', + }; + } + }, +); + +export const syncDomainAction = domainsStatePart.createAction<{ id: string }>( + async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_SyncDomain + >('/typedrequest', 'syncDomain'); + const response = await request.fire({ identity: context.identity!, id: dataArg.id }); + if (!response.success) { + return { ...statePartArg.getState()!, error: response.message || 'Failed to sync domain' }; + } + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to sync domain', + }; + } + }, +); + +export const createDnsRecordAction = domainsStatePart.createAction<{ + domainId: string; + name: string; + type: interfaces.data.TDnsRecordType; + value: string; + ttl?: number; + proxied?: boolean; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateDnsRecord + >('/typedrequest', 'createDnsRecord'); + const response = await request.fire({ + identity: context.identity!, + domainId: dataArg.domainId, + name: dataArg.name, + type: dataArg.type, + value: dataArg.value, + ttl: dataArg.ttl, + proxied: dataArg.proxied, + }); + if (!response.success) { + return { ...statePartArg.getState()!, error: response.message || 'Failed to create record' }; + } + return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId }); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to create record', + }; + } +}); + +export const updateDnsRecordAction = domainsStatePart.createAction<{ + id: string; + domainId: string; + name?: string; + value?: string; + ttl?: number; + proxied?: boolean; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateDnsRecord + >('/typedrequest', 'updateDnsRecord'); + const response = await request.fire({ + identity: context.identity!, + id: dataArg.id, + name: dataArg.name, + value: dataArg.value, + ttl: dataArg.ttl, + proxied: dataArg.proxied, + }); + if (!response.success) { + return { ...statePartArg.getState()!, error: response.message || 'Failed to update record' }; + } + return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId }); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to update record', + }; + } +}); + +export const deleteDnsRecordAction = domainsStatePart.createAction<{ id: string; domainId: string }>( + async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteDnsRecord + >('/typedrequest', 'deleteDnsRecord'); + const response = await request.fire({ identity: context.identity!, id: dataArg.id }); + if (!response.success) { + return { ...statePartArg.getState()!, error: response.message || 'Failed to delete record' }; + } + return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId }); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to delete record', + }; + } + }, +); + // ============================================================================ // Route Management Actions // ============================================================================ @@ -2076,8 +2474,8 @@ async function dispatchCombinedRefreshActionInner() { } } - // Refresh certificate data if on certificates view - if (currentView === 'certificates') { + // Refresh certificate data if on Domains > Certificates subview + if (currentView === 'domains' && currentSubview === 'certificates') { try { await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); } catch (error) { diff --git a/ts_web/elements/access/ops-view-apitokens.ts b/ts_web/elements/access/ops-view-apitokens.ts index 24d2339..4f7fa64 100644 --- a/ts_web/elements/access/ops-view-apitokens.ts +++ b/ts_web/elements/access/ops-view-apitokens.ts @@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement { const { apiTokens } = this.routeState; return html` - API Tokens + API Tokens
Users + Users
Certificates + Certificates
${this.renderStatsTiles(summary)} diff --git a/ts_web/elements/domains/ops-view-dns.ts b/ts_web/elements/domains/ops-view-dns.ts new file mode 100644 index 0000000..c0cda0c --- /dev/null +++ b/ts_web/elements/domains/ops-view-dns.ts @@ -0,0 +1,273 @@ +import { + DeesElement, + html, + customElement, + type TemplateResult, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; +import { viewHostCss } from '../shared/css.js'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-dns': OpsViewDns; + } +} + +const RECORD_TYPES: interfaces.data.TDnsRecordType[] = [ + 'A', + 'AAAA', + 'CNAME', + 'MX', + 'TXT', + 'NS', + 'CAA', +]; + +@customElement('ops-view-dns') +export class OpsViewDns extends DeesElement { + @state() + accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.domainsStatePart.select().subscribe((newState) => { + this.domainsState = newState; + }); + this.rxSubscriptions.push(sub); + } + + async connectedCallback() { + await super.connectedCallback(); + await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null); + // If a domain is already selected (e.g. via "View Records" navigation), refresh its records + const selected = this.domainsState.selectedDomainId; + if (selected) { + await appstate.domainsStatePart.dispatchAction(appstate.fetchDnsRecordsForDomainAction, { + domainId: selected, + }); + } + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .dnsContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .domainPicker { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: ${cssManager.bdTheme('#f9fafb', '#111827')}; + border-radius: 8px; + } + + .sourceBadge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + } + + .sourceBadge.manual { + background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')}; + color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')}; + } + + .sourceBadge.synced { + background: ${cssManager.bdTheme('#fef3c7', '#451a03')}; + color: ${cssManager.bdTheme('#92400e', '#fde047')}; + } + `, + ]; + + public render(): TemplateResult { + const domains = this.domainsState.domains; + const selectedId = this.domainsState.selectedDomainId; + const records = this.domainsState.records; + + return html` + DNS Records +
+
+ Domain: + ({ option: d.name, key: d.id }))} + .selectedOption=${selectedId + ? { option: domains.find((d) => d.id === selectedId)?.name || '', key: selectedId } + : undefined} + @selectedOption=${async (e: CustomEvent) => { + const id = (e.detail as any)?.key; + if (!id) return; + await appstate.domainsStatePart.dispatchAction( + appstate.fetchDnsRecordsForDomainAction, + { domainId: id }, + ); + }} + > +
+ + ${selectedId + ? html` + ({ + Name: r.name, + Type: r.type, + Value: r.value, + TTL: r.ttl, + Source: html`${r.source}`, + })} + .dataActions=${[ + { + name: 'Add Record', + iconName: 'lucide:plus', + type: ['header' as const], + actionFunc: async () => { + await this.showCreateRecordDialog(selectedId); + }, + }, + { + name: 'Refresh', + iconName: 'lucide:rotateCw', + type: ['header' as const], + actionFunc: async () => { + await appstate.domainsStatePart.dispatchAction( + appstate.fetchDnsRecordsForDomainAction, + { domainId: selectedId }, + ); + }, + }, + { + name: 'Edit', + iconName: 'lucide:pencil', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const rec = actionData.item as interfaces.data.IDnsRecord; + await this.showEditRecordDialog(rec); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const rec = actionData.item as interfaces.data.IDnsRecord; + await appstate.domainsStatePart.dispatchAction( + appstate.deleteDnsRecordAction, + { id: rec.id, domainId: rec.domainId }, + ); + }, + }, + ]} + > + ` + : html`

Pick a domain above to view its records.

`} +
+ `; + } + + private domainHint(domainId: string): string { + const domain = this.domainsState.domains.find((d) => d.id === domainId); + if (!domain) return ''; + if (domain.source === 'manual') { + return 'Records are served by dcrouter (authoritative).'; + } + return 'Records are stored at the provider — changes here are pushed via the provider API.'; + } + + private async showCreateRecordDialog(domainId: string) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Add DNS Record', + content: html` + + + ({ option: t, key: t }))} + .required=${true} + > + + + + `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Create', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot + ?.querySelector('.content') + ?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const type = (data.type?.key ?? data.type) as interfaces.data.TDnsRecordType; + await appstate.domainsStatePart.dispatchAction(appstate.createDnsRecordAction, { + domainId, + name: String(data.name), + type, + value: String(data.value), + ttl: parseInt(String(data.ttl || '300'), 10), + }); + modalArg.destroy(); + }, + }, + ], + }); + } + + private async showEditRecordDialog(rec: interfaces.data.IDnsRecord) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: `Edit ${rec.type} ${rec.name}`, + content: html` + + + + + + `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Save', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot + ?.querySelector('.content') + ?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + await appstate.domainsStatePart.dispatchAction(appstate.updateDnsRecordAction, { + id: rec.id, + domainId: rec.domainId, + name: String(data.name), + value: String(data.value), + ttl: parseInt(String(data.ttl || '300'), 10), + }); + modalArg.destroy(); + }, + }, + ], + }); + } +} diff --git a/ts_web/elements/domains/ops-view-domains.ts b/ts_web/elements/domains/ops-view-domains.ts new file mode 100644 index 0000000..27e0c83 --- /dev/null +++ b/ts_web/elements/domains/ops-view-domains.ts @@ -0,0 +1,335 @@ +import { + DeesElement, + html, + customElement, + type TemplateResult, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; +import { viewHostCss } from '../shared/css.js'; +import { appRouter } from '../../router.js'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-domains': OpsViewDomains; + } +} + +@customElement('ops-view-domains') +export class OpsViewDomains extends DeesElement { + @state() + accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.domainsStatePart.select().subscribe((newState) => { + this.domainsState = newState; + }); + this.rxSubscriptions.push(sub); + } + + async connectedCallback() { + await super.connectedCallback(); + await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .domainsContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .sourceBadge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + } + + .sourceBadge.manual { + background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')}; + color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')}; + } + + .sourceBadge.provider { + background: ${cssManager.bdTheme('#fef3c7', '#451a03')}; + color: ${cssManager.bdTheme('#92400e', '#fde047')}; + } + `, + ]; + + public render(): TemplateResult { + const domains = this.domainsState.domains; + const providersById = new Map(this.domainsState.providers.map((p) => [p.id, p])); + + return html` + Domains +
+ ({ + Name: d.name, + Source: this.renderSourceBadge(d, providersById), + Authoritative: d.authoritative ? 'yes' : 'no', + Nameservers: d.nameservers?.join(', ') || '-', + 'Last Synced': d.lastSyncedAt + ? new Date(d.lastSyncedAt).toLocaleString() + : '-', + })} + .dataActions=${[ + { + name: 'Add Manual Domain', + iconName: 'lucide:plus', + type: ['header' as const], + actionFunc: async () => { + await this.showCreateManualDialog(); + }, + }, + { + name: 'Import from Provider', + iconName: 'lucide:download', + type: ['header' as const], + actionFunc: async () => { + await this.showImportDialog(); + }, + }, + { + name: 'Refresh', + iconName: 'lucide:rotateCw', + type: ['header' as const], + actionFunc: async () => { + await appstate.domainsStatePart.dispatchAction( + appstate.fetchDomainsAndProvidersAction, + null, + ); + }, + }, + { + name: 'View Records', + iconName: 'lucide:list', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const domain = actionData.item as interfaces.data.IDomain; + await appstate.domainsStatePart.dispatchAction( + appstate.fetchDnsRecordsForDomainAction, + { domainId: domain.id }, + ); + appRouter.navigateToView('domains', 'dns'); + }, + }, + { + name: 'Sync Now', + iconName: 'lucide:rotateCw', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const domain = actionData.item as interfaces.data.IDomain; + if (domain.source !== 'provider') { + const { DeesToast } = await import('@design.estate/dees-catalog'); + DeesToast.show({ + message: 'Sync only applies to provider-managed domains', + type: 'warning', + duration: 3000, + }); + return; + } + await appstate.domainsStatePart.dispatchAction(appstate.syncDomainAction, { + id: domain.id, + }); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const domain = actionData.item as interfaces.data.IDomain; + await this.deleteDomain(domain); + }, + }, + ]} + > +
+ `; + } + + private renderSourceBadge( + d: interfaces.data.IDomain, + providersById: Map, + ): TemplateResult { + if (d.source === 'manual') { + return html`Manual`; + } + const provider = d.providerId ? providersById.get(d.providerId) : undefined; + return html`${provider?.name || 'Provider'}`; + } + + private async showCreateManualDialog() { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Add Manual Domain', + content: html` + + + + +

+ dcrouter will become the authoritative DNS server for this domain. You'll need to + delegate the domain's nameservers to dcrouter to make this effective. +

+ `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Create', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot + ?.querySelector('.content') + ?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + await appstate.domainsStatePart.dispatchAction(appstate.createManualDomainAction, { + name: String(data.name), + description: data.description ? String(data.description) : undefined, + }); + modalArg.destroy(); + }, + }, + ], + }); + } + + private async showImportDialog() { + const providers = this.domainsState.providers; + if (providers.length === 0) { + const { DeesToast } = await import('@design.estate/dees-catalog'); + DeesToast.show({ + message: 'Add a DNS provider first (Domains > Providers)', + type: 'warning', + duration: 3500, + }); + return; + } + + const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Import Domains from Provider', + content: html` + + ({ option: p.name, key: p.id }))} + .required=${true} + > + + +

+ Tip: use "List Provider Domains" to see what's available before typing. +

+ `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'List Provider Domains', + action: async (_modalArg: any) => { + const form = _modalArg.shadowRoot + ?.querySelector('.content') + ?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const providerKey = data.providerId?.key ?? data.providerId; + if (!providerKey) { + DeesToast.show({ message: 'Pick a provider first', type: 'warning', duration: 2500 }); + return; + } + const result = await appstate.fetchProviderDomains(String(providerKey)); + if (!result.success) { + DeesToast.show({ + message: result.message || 'Failed to fetch domains', + type: 'error', + duration: 4000, + }); + return; + } + const list = (result.domains ?? []).map((d) => d.name).join(', '); + DeesToast.show({ + message: `Provider has: ${list || '(none)'}`, + type: 'info', + duration: 8000, + }); + }, + }, + { + name: 'Import', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot + ?.querySelector('.content') + ?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const providerKey = data.providerId?.key ?? data.providerId; + if (!providerKey) { + DeesToast.show({ message: 'Pick a provider', type: 'warning', duration: 2500 }); + return; + } + const names = String(data.domainNames || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (names.length === 0) { + DeesToast.show({ message: 'Enter at least one FQDN', type: 'warning', duration: 2500 }); + return; + } + await appstate.domainsStatePart.dispatchAction( + appstate.importDomainsFromProviderAction, + { providerId: String(providerKey), domainNames: names }, + ); + modalArg.destroy(); + }, + }, + ], + }); + } + + private async deleteDomain(domain: interfaces.data.IDomain) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: `Delete domain ${domain.name}?`, + content: html` +

+ ${domain.source === 'provider' + ? 'This removes the domain and its cached records from dcrouter only. The zone at the provider is NOT touched.' + : 'This removes the domain and all of its DNS records from dcrouter. dcrouter will no longer answer queries for this domain after the next restart.'} +

+ `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Delete', + action: async (modalArg: any) => { + await appstate.domainsStatePart.dispatchAction(appstate.deleteDomainAction, { + id: domain.id, + }); + modalArg.destroy(); + }, + }, + ], + }); + } +} diff --git a/ts_web/elements/domains/ops-view-providers.ts b/ts_web/elements/domains/ops-view-providers.ts new file mode 100644 index 0000000..3501537 --- /dev/null +++ b/ts_web/elements/domains/ops-view-providers.ts @@ -0,0 +1,283 @@ +import { + DeesElement, + html, + customElement, + type TemplateResult, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; +import { viewHostCss } from '../shared/css.js'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-providers': OpsViewProviders; + } +} + +@customElement('ops-view-providers') +export class OpsViewProviders extends DeesElement { + @state() + accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.domainsStatePart.select().subscribe((newState) => { + this.domainsState = newState; + }); + this.rxSubscriptions.push(sub); + } + + async connectedCallback() { + await super.connectedCallback(); + await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .providersContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .statusBadge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + } + + .statusBadge.ok { + background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; + color: ${cssManager.bdTheme('#166534', '#4ade80')}; + } + + .statusBadge.error { + background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; + color: ${cssManager.bdTheme('#991b1b', '#f87171')}; + } + + .statusBadge.untested { + background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; + color: ${cssManager.bdTheme('#4b5563', '#9ca3af')}; + } + `, + ]; + + public render(): TemplateResult { + const providers = this.domainsState.providers; + + return html` + DNS Providers +
+ ({ + Name: p.name, + Type: p.type, + Status: this.renderStatusBadge(p.status), + 'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never', + Error: p.lastError || '-', + })} + .dataActions=${[ + { + name: 'Add Provider', + iconName: 'lucide:plus', + type: ['header' as const], + actionFunc: async () => { + await this.showCreateDialog(); + }, + }, + { + name: 'Refresh', + iconName: 'lucide:rotateCw', + type: ['header' as const], + actionFunc: async () => { + await appstate.domainsStatePart.dispatchAction( + appstate.fetchDomainsAndProvidersAction, + null, + ); + }, + }, + { + name: 'Test Connection', + iconName: 'lucide:plug', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const provider = actionData.item as interfaces.data.IDnsProviderPublic; + await this.testProvider(provider); + }, + }, + { + name: 'Edit', + iconName: 'lucide:pencil', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const provider = actionData.item as interfaces.data.IDnsProviderPublic; + await this.showEditDialog(provider); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const provider = actionData.item as interfaces.data.IDnsProviderPublic; + await this.deleteProvider(provider); + }, + }, + ]} + > +
+ `; + } + + private renderStatusBadge(status: interfaces.data.TDnsProviderStatus): TemplateResult { + return html`${status}`; + } + + private async showCreateDialog() { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Add DNS Provider', + content: html` + + + + + `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Create', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot + ?.querySelector('.content') + ?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + await appstate.domainsStatePart.dispatchAction(appstate.createDnsProviderAction, { + name: String(data.name), + type: 'cloudflare', + credentials: { type: 'cloudflare', apiToken: String(data.apiToken) }, + }); + modalArg.destroy(); + }, + }, + ], + }); + } + + private async showEditDialog(provider: interfaces.data.IDnsProviderPublic) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: `Edit Provider: ${provider.name}`, + content: html` + + + + + `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Save', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot + ?.querySelector('.content') + ?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const apiToken = data.apiToken ? String(data.apiToken) : ''; + await appstate.domainsStatePart.dispatchAction(appstate.updateDnsProviderAction, { + id: provider.id, + name: String(data.name), + credentials: apiToken + ? { type: 'cloudflare', apiToken } + : undefined, + }); + modalArg.destroy(); + }, + }, + ], + }); + } + + private async testProvider(provider: interfaces.data.IDnsProviderPublic) { + const { DeesToast } = await import('@design.estate/dees-catalog'); + await appstate.domainsStatePart.dispatchAction(appstate.testDnsProviderAction, { + id: provider.id, + }); + const updated = appstate.domainsStatePart + .getState()! + .providers.find((p) => p.id === provider.id); + if (updated?.status === 'ok') { + DeesToast.show({ + message: `${provider.name}: connection OK`, + type: 'success', + duration: 3000, + }); + } else { + DeesToast.show({ + message: `${provider.name}: ${updated?.lastError || 'connection failed'}`, + type: 'error', + duration: 4000, + }); + } + } + + private async deleteProvider(provider: interfaces.data.IDnsProviderPublic) { + const linkedDomains = this.domainsState.domains.filter((d) => d.providerId === provider.id); + const { DeesModal } = await import('@design.estate/dees-catalog'); + + const doDelete = async (force: boolean) => { + await appstate.domainsStatePart.dispatchAction(appstate.deleteDnsProviderAction, { + id: provider.id, + force, + }); + }; + + if (linkedDomains.length > 0) { + DeesModal.createAndShow({ + heading: `Provider in use`, + content: html` +

+ Provider ${provider.name} is referenced by ${linkedDomains.length} + domain(s). Deleting will also remove the imported domain(s) and their cached + records (the records at ${provider.type} are NOT touched). +

+ `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Force Delete', + action: async (modalArg: any) => { + await doDelete(true); + modalArg.destroy(); + }, + }, + ], + }); + } else { + await doDelete(false); + } + } +} diff --git a/ts_web/elements/email/ops-view-email-security.ts b/ts_web/elements/email/ops-view-email-security.ts index a16c5bf..766b67b 100644 --- a/ts_web/elements/email/ops-view-email-security.ts +++ b/ts_web/elements/email/ops-view-email-security.ts @@ -111,7 +111,7 @@ export class OpsViewEmailSecurity extends DeesElement { ]; return html` - Email Security + Email Security Email Log + Email Log
${this.currentView === 'detail' && this.selectedEmail ? html` diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 6e6fd4b..a8d397f 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -5,5 +5,5 @@ export * from './email/index.js'; export * from './ops-view-logs.js'; export * from './access/index.js'; export * from './security/index.js'; -export * from './ops-view-certificates.js'; +export * from './domains/index.js'; export * from './shared/index.js'; diff --git a/ts_web/elements/network/ops-view-network-activity.ts b/ts_web/elements/network/ops-view-network-activity.ts index e4adaa1..a1fd61b 100644 --- a/ts_web/elements/network/ops-view-network-activity.ts +++ b/ts_web/elements/network/ops-view-network-activity.ts @@ -285,7 +285,7 @@ export class OpsViewNetworkActivity extends DeesElement { public render() { return html` - Network Activity + Network Activity
diff --git a/ts_web/elements/network/ops-view-networktargets.ts b/ts_web/elements/network/ops-view-networktargets.ts index 34fce82..a1b8779 100644 --- a/ts_web/elements/network/ops-view-networktargets.ts +++ b/ts_web/elements/network/ops-view-networktargets.ts @@ -64,7 +64,7 @@ export class OpsViewNetworkTargets extends DeesElement { ]; return html` - Network Targets + Network Targets
Remote Ingress + Remote Ingress ${this.riState.newEdgeId ? html`
diff --git a/ts_web/elements/network/ops-view-routes.ts b/ts_web/elements/network/ops-view-routes.ts index d691338..031f8b4 100644 --- a/ts_web/elements/network/ops-view-routes.ts +++ b/ts_web/elements/network/ops-view-routes.ts @@ -200,7 +200,7 @@ export class OpsViewRoutes extends DeesElement { }); return html` - Route Management + Route Management
Source Profiles + Source Profiles
Target Profiles + Target Profiles
VPN + VPN
${this.vpnState.newClientConfig ? html` diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 9d4acc1..a7a1df2 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -15,7 +15,6 @@ import type { IView } from '@design.estate/dees-catalog'; // Top-level / flat views import { OpsViewLogs } from './ops-view-logs.js'; -import { OpsViewCertificates } from './ops-view-certificates.js'; // Overview group import { OpsViewOverview } from './overview/ops-view-overview.js'; @@ -43,6 +42,12 @@ import { OpsViewSecurityOverview } from './security/ops-view-security-overview.j import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js'; import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js'; +// Domains group +import { OpsViewProviders } from './domains/ops-view-providers.js'; +import { OpsViewDomains } from './domains/ops-view-domains.js'; +import { OpsViewDns } from './domains/ops-view-dns.js'; +import { OpsViewCertificates } from './domains/ops-view-certificates.js'; + /** * Extended IView with explicit URL slug. Without an explicit `slug`, the URL * slug is derived from `name.toLowerCase().replace(/\s+/g, '')`. @@ -128,9 +133,14 @@ export class OpsDashboard extends DeesElement { ], }, { - name: 'Certificates', - iconName: 'lucide:badgeCheck', - element: OpsViewCertificates, + name: 'Domains', + iconName: 'lucide:globe', + subViews: [ + { slug: 'providers', name: 'Providers', iconName: 'lucide:plug', element: OpsViewProviders }, + { slug: 'domains', name: 'Domains', iconName: 'lucide:globe', element: OpsViewDomains }, + { slug: 'dns', name: 'DNS', iconName: 'lucide:list', element: OpsViewDns }, + { slug: 'certificates', name: 'Certificates', iconName: 'lucide:badgeCheck', element: OpsViewCertificates }, + ], }, ]; diff --git a/ts_web/elements/ops-view-logs.ts b/ts_web/elements/ops-view-logs.ts index 7458467..ee6b645 100644 --- a/ts_web/elements/ops-view-logs.ts +++ b/ts_web/elements/ops-view-logs.ts @@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement { public render() { return html` - Logs + Logs Configuration + Configuration ${this.configState.isLoading ? html` @@ -227,7 +227,7 @@ export class OpsViewConfig extends DeesElement { const status = tls.source === 'none' ? 'not-configured' : 'enabled'; const actions: IConfigSectionAction[] = [ - { label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } }, + { label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'domains', subview: 'certificates' } }, ]; return html` diff --git a/ts_web/elements/overview/ops-view-overview.ts b/ts_web/elements/overview/ops-view-overview.ts index a9cad8e..93d4e1f 100644 --- a/ts_web/elements/overview/ops-view-overview.ts +++ b/ts_web/elements/overview/ops-view-overview.ts @@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement { public render() { return html` - Stats + Stats ${this.statsState.isLoading ? html`
diff --git a/ts_web/elements/security/ops-view-security-authentication.ts b/ts_web/elements/security/ops-view-security-authentication.ts index 23c9a84..d230da4 100644 --- a/ts_web/elements/security/ops-view-security-authentication.ts +++ b/ts_web/elements/security/ops-view-security-authentication.ts @@ -96,7 +96,7 @@ export class OpsViewSecurityAuthentication extends DeesElement { })); return html` - Authentication + Authentication Blocked IPs + Blocked IPs Overview + Overview = { @@ -13,6 +13,7 @@ const subviewMap: Record = { email: ['log', 'security'] as const, access: ['apitokens', 'users'] as const, security: ['overview', 'blocked', 'authentication'] as const, + domains: ['providers', 'domains', 'dns', 'certificates'] as const, }; // Default subview when user visits the bare parent URL @@ -22,6 +23,7 @@ const defaultSubview: Record = { email: 'log', access: 'apitokens', security: 'overview', + domains: 'domains', }; export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const;