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'; import { decodeTxtRecordContent, encodeCloudflareTxtContent, } from '../txt-record-presentation.js'; interface ICloudflareApiRecord { id?: unknown; name?: unknown; type?: unknown; content?: unknown; ttl?: unknown; priority?: unknown; proxied?: unknown; } /** * 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; private recordListingPromises = new Map>(); constructor(apiToken: string) { if (!apiToken) { throw new Error('CloudflareDnsProvider: apiToken is required'); } this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken); } 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.listApiRecords(domain); return records .filter((record) => typeof record.type === 'string' && this.isSupportedType(record.type)) .map((r) => this.fromApiRecord(r)); } public async listRecordIds(domain: string): Promise { return (await this.listApiRecords(domain)).map((record) => { const providerRecordId = typeof record?.id === 'string' ? record.id.trim() : ''; if (!providerRecordId) { throw new Error('CloudflareDnsProvider: provider response has no record id'); } return providerRecordId; }); } private async listApiRecords(domain: string): Promise { const key = domain.trim().toLowerCase(); const pending = this.recordListingPromises.get(key); if (pending) return await pending; const listing = this.cfAccount.recordManager.listRecords(domain); this.recordListingPromises.set(key, listing); try { return await listing; } finally { if (this.recordListingPromises.get(key) === listing) { this.recordListingPromises.delete(key); } } } 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, ttl: record.ttl ?? 1, // 1 = automatic ...this.toApiRecordValue(record), }; if (record.proxied !== undefined) { apiRecord.proxied = record.proxied; } const created = await this.cfAccount.apiAccount.dns.records.create(apiRecord); return this.fromApiRecord(created); } 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, ttl: record.ttl ?? 1, ...this.toApiRecordValue(record), }; if (record.proxied !== undefined) { apiRecord.proxied = record.proxied; } const updated = await this.cfAccount.apiAccount.dns.records.edit( providerRecordId, apiRecord, ); return this.fromApiRecord(updated); } public async deleteRecord(domain: string, providerRecordId: string): Promise { const zoneId = await this.cfAccount.zoneManager.getZoneId(domain); await this.cfAccount.apiAccount.dns.records.delete(providerRecordId, { zone_id: zoneId, }); } private isSupportedType(type: string): boolean { return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type); } private toApiRecordValue(record: IProviderRecordInput): { content: string; priority?: number } { if (record.type === 'TXT') { return { content: encodeCloudflareTxtContent(record.value) }; } if (record.type !== 'MX') { return { content: record.value }; } const match = record.value.trim().match(/^(\d{1,5})\s+([^\s]+)$/); if (!match) { throw new Error(`CloudflareDnsProvider: invalid MX value "${record.value}"; expected " "`); } const priority = Number.parseInt(match[1], 10); if (!Number.isInteger(priority) || priority < 0 || priority > 65535) { throw new Error(`CloudflareDnsProvider: invalid MX priority in "${record.value}"`); } const exchange = match[2]; if (!this.isValidMxExchange(exchange, priority)) { throw new Error(`CloudflareDnsProvider: invalid MX exchange in "${record.value}"`); } return { content: exchange, priority }; } private fromApiRecord(record: ICloudflareApiRecord): IProviderRecord { const providerRecordId = typeof record?.id === 'string' ? record.id.trim() : ''; const name = typeof record?.name === 'string' ? record.name.trim() : ''; const type = typeof record?.type === 'string' ? record.type : ''; const ttl = Number(record?.ttl); if (!providerRecordId) throw new Error('CloudflareDnsProvider: provider response has no record id'); if (!name) throw new Error('CloudflareDnsProvider: provider response has no record name'); if (!this.isSupportedType(type)) { throw new Error(`CloudflareDnsProvider: provider response has unsupported record type ${type || ''}`); } if (!Number.isInteger(ttl) || ttl < 1) { throw new Error(`CloudflareDnsProvider: provider response has invalid TTL ${String(record?.ttl)}`); } if (typeof record?.content !== 'string' || (type !== 'TXT' && !record.content.trim())) { throw new Error(`CloudflareDnsProvider: provider response has invalid content for ${type} ${name}`); } let value: string; if (type === 'MX') { const priority = Number(record.priority); const exchange = record.content.trim(); if (!Number.isInteger(priority) || priority < 0 || priority > 65_535) { throw new Error(`CloudflareDnsProvider: provider response has invalid MX priority ${String(record.priority)}`); } if (!this.isValidMxExchange(exchange, priority)) { throw new Error(`CloudflareDnsProvider: provider response has invalid MX exchange ${exchange}`); } value = `${priority} ${exchange}`; } else if (type === 'TXT') { const decoded = decodeTxtRecordContent(String(record.content)); if (!decoded.ok) { throw new Error( `CloudflareDnsProvider: malformed TXT content for ${String(record.name)}: ${decoded.error}`, ); } value = decoded.value; } else { value = String(record.content); } return { providerRecordId, name, type: type as TDnsRecordType, value, ttl, ...(record.proxied !== undefined ? { proxied: Boolean(record.proxied) } : {}), }; } private isValidHostname(value: string): boolean { const hostname = value.toLowerCase().replace(/\.$/, ''); return hostname.length > 0 && hostname.length <= 253 && hostname.split('.').every((label) => ( label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) )); } private isValidMxExchange(value: string, priority: number): boolean { return value === '.' ? priority === 0 : this.isValidHostname(value); } }