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); } }