/** * Cloudflare DNS Provider Adapter * Manages DNS records via the Cloudflare API v4. */ const BaseDNSProvider = require('./base'); const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; class CloudflareDNSProvider extends BaseDNSProvider { constructor(config, ctx) { super(config, ctx); this.providerId = 'cloudflare'; this.displayName = 'Cloudflare DNS'; // Resolve API token: explicit config takes priority, then credential manager this.apiToken = config.apiToken || (ctx.credentialManager && ctx.credentialManager.get('dns.cloudflare.apiToken')) || null; this.zoneId = config.zoneId || null; this.domain = config.domain || null; } // ── Helpers ──────────────────────────────────────────────────────────── /** Build common request headers for Cloudflare API calls */ _headers() { return { 'Authorization': `Bearer ${this.apiToken}`, 'Content-Type': 'application/json', }; } /** Make an authenticated request to the Cloudflare API */ async _cfRequest(method, path, body) { const url = `${CF_API_BASE}${path}`; const opts = { method, headers: this._headers(), }; if (body !== undefined) { opts.body = JSON.stringify(body); } return this.ctx.fetchT(url, opts); } /** Map a Cloudflare DNS record to the normalised format expected by routes */ _mapRecord(rec) { return { id: rec.id, type: rec.type, name: rec.name, value: rec.content, ttl: rec.ttl, proxied: rec.proxied || false, }; } // ── Capabilities ─────────────────────────────────────────────────────── supportsCapability(cap) { return this.getCapabilities().includes(cap); } getCapabilities() { return ['create-record', 'delete-record', 'resolve', 'list-records', 'credentials', 'zones']; } // ── Authentication ───────────────────────────────────────────────────── /** * Validate the API token by calling the Cloudflare verify endpoint. * Stores basic zone info on success. */ async authenticate() { this.ctx.log('[cloudflare] Authenticating – verifying API token…'); if (!this.apiToken) { return { status: 'error', message: 'No Cloudflare API token provided' }; } const res = await this._cfRequest('GET', '/user/tokens/verify'); const data = await res.json(); if (!data.success) { const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Token verification failed'; this.ctx.log(`[cloudflare] Authentication failed: ${msg}`); return { status: 'error', message: msg }; } this.ctx.log(`[cloudflare] Token verified for status "${data.status}"`); // Optionally fetch zone info if zoneId is configured if (this.zoneId) { try { const zoneRes = await this._cfRequest('GET', `/zones/${this.zoneId}`); const zoneData = await zoneRes.json(); if (zoneData.success && zoneData.result) { this.zoneInfo = zoneData.result; this.ctx.log(`[cloudflare] Zone loaded: ${zoneData.result.name} (${zoneData.result.id})`); } } catch (err) { this.ctx.log(`[cloudflare] Could not fetch zone info: ${err.message}`); } } return { status: 'ok', response: { status: data.status } }; } // ── Create Record ────────────────────────────────────────────────────── /** * Create a DNS record. * If overwrite is true, first delete any existing record with the same name+type. */ async createRecord({ domain, zone, type, value, ttl, overwrite }) { const targetDomain = domain || this.domain; const targetZone = zone || this.zoneId; if (!targetZone) { return { status: 'error', message: 'No zone ID configured for Cloudflare' }; } if (overwrite) { this.ctx.log(`[cloudflare] Overwrite requested – deleting existing ${type} record for ${targetDomain}`); try { await this.deleteRecord({ domain: targetDomain, type, value }); } catch (err) { this.ctx.log(`[cloudflare] No existing record to overwrite (or delete failed): ${err.message}`); } } const body = { type, name: targetDomain, content: value, ttl: ttl || 1, // 1 = automatic TTL in Cloudflare proxied: false, }; this.ctx.log(`[cloudflare] Creating ${type} record: ${targetDomain} → ${value}`); const res = await this._cfRequest('POST', `/zones/${targetZone}/dns_records`, body); const data = await res.json(); if (!data.success) { const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Record creation failed'; this.ctx.log(`[cloudflare] Create failed: ${msg}`); return { status: 'error', message: msg }; } return { status: 'ok', response: { record: this._mapRecord(data.result) } }; } // ── Delete Record ────────────────────────────────────────────────────── /** * Delete DNS records matching domain+type. * Lists matching records first, then deletes each one. */ async deleteRecord({ domain, type, value }) { const targetDomain = domain || this.domain; const targetZone = this.zoneId; if (!targetZone) { return { status: 'error', message: 'No zone ID configured for Cloudflare' }; } // List records matching name + type let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`; if (type) { queryPath += `&type=${encodeURIComponent(type)}`; } const listRes = await this._cfRequest('GET', queryPath); const listData = await listRes.json(); if (!listData.success) { const msg = (listData.errors && listData.errors[0] && listData.errors[0].message) || 'Failed to list records for deletion'; this.ctx.log(`[cloudflare] Delete – list failed: ${msg}`); return { status: 'error', message: msg }; } const matching = listData.result || []; if (matching.length === 0) { this.ctx.log(`[cloudflare] No records found for ${targetDomain} (${type || 'any type'})`); return { status: 'ok', response: { deleted: 0 } }; } // If a specific value is given, only delete records matching that value const toDelete = value ? matching.filter((r) => r.content === value) : matching; let deleted = 0; for (const record of toDelete) { const delRes = await this._cfRequest('DELETE', `/zones/${targetZone}/dns_records/${record.id}`); const delData = await delRes.json(); if (delData.success) { deleted++; this.ctx.log(`[cloudflare] Deleted record ${record.id} (${record.type} ${record.name})`); } else { const msg = (delData.errors && delData.errors[0] && delData.errors[0].message) || 'Delete failed'; this.ctx.log(`[cloudflare] Failed to delete record ${record.id}: ${msg}`); } } return { status: 'ok', response: { deleted } }; } // ── Resolve Records ─────────────────────────────────────────────────── /** * Resolve/query existing records for a domain. * Returns records matching domain (and optionally type). */ async resolveRecords({ domain, zone, type }) { const targetDomain = domain || this.domain; const targetZone = zone || this.zoneId; if (!targetZone) { return { status: 'error', message: 'No zone ID configured for Cloudflare' }; } let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`; if (type) { queryPath += `&type=${encodeURIComponent(type)}`; } this.ctx.log(`[cloudflare] Resolving records for ${targetDomain}${type ? ` (${type})` : ''}`); const res = await this._cfRequest('GET', queryPath); const data = await res.json(); if (!data.success) { const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Resolve failed'; this.ctx.log(`[cloudflare] Resolve failed: ${msg}`); return { status: 'error', message: msg }; } const records = (data.result || []).map(this._mapRecord); return { status: 'ok', response: { records } }; } // ── List Records ─────────────────────────────────────────────────────── /** * List all DNS records in a zone. */ async listRecords({ zone }) { const targetZone = zone || this.zoneId; if (!targetZone) { return { status: 'error', message: 'No zone ID configured for Cloudflare' }; } this.ctx.log(`[cloudflare] Listing all records in zone ${targetZone}`); const res = await this._cfRequest('GET', `/zones/${targetZone}/dns_records`); const data = await res.json(); if (!data.success) { const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'List failed'; this.ctx.log(`[cloudflare] List failed: ${msg}`); return { status: 'error', message: msg }; } const records = (data.result || []).map(this._mapRecord); return { status: 'ok', response: { records } }; } } module.exports = CloudflareDNSProvider;