- dns-providers/: adapter base class + registry with auto-discovery - technitium.js: wraps existing Technitium API calls into adapter interface - cloudflare.js: Cloudflare API v4 adapter (zones, records, credentials) - rfc2136.js: RFC 2136 dynamic DNS via nsupdate (BIND, PowerDNS, etc.) - manual.js: no-op adapter for external DNS management with instructions - provider-dns.js: provider-aware DNS context, resolves active adapter from config - Universal helper methods: universalCreateRecord/Delete/ResolveRecord - All 7 route files updated to use universal methods instead of raw dns.call() - Setup wizard: provider dropdown (Technitium, Cloudflare, RFC 2136, Manual) - DNS template selector: added Cloudflare and External/Manual options - Config schema: validates dns.provider field - Capability gating on Technitium-specific endpoints (logs, restart, update) - Backward compatible: no provider set = auto-detect (technitium if dns.ip exists)
270 lines
9.6 KiB
JavaScript
270 lines
9.6 KiB
JavaScript
/**
|
||
* 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;
|