feat: DNS provider abstraction — Technitium, Cloudflare, RFC 2136, Manual
- 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)
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user