In place of that, this is the entire patch. You can save it and apply it with git apply <filename>.
From 15c099056f54257130a856a9d68b511be9e090f1 Mon Sep 17 00:00:00 2001
From: Jacob Kiers <code@kiers.eu>
Date: Tue, 14 Apr 2026 17:19:08 +0000
Subject: [PATCH] Implement PowerDNS provider
---
dashboard/public/translation/en.json | 2 +
.../src/components/DomainProviderForm.vue | 11 ++
dashboard/src/models/DomainsModel.js | 4 +
src/dns.js | 3 +-
src/dns/powerdns.js | 165 ++++++++++++++++++
src/test/dns-providers-test.js | 73 ++++++++
7 files changed, 257 insertions(+), 9 deletions(-)
create mode 100644 src/dns/powerdns.js
diff --git a/dashboard/public/translation/en.json b/dashboard/public/translation/en.json
index 4ba29505f..257ab1e22 100644
--- a/dashboard/public/translation/en.json
+++ b/dashboard/public/translation/en.json
@@ -825,6 +825,8 @@
"domain": "Domain",
"provider": "DNS provider",
"route53AccessKeyId": "Access key ID",
+ "powerdnsApiUrl": "PowerDNS API URL (e.g., https://ns1.example.com:8081)",
+ "powerdnsApiKey": "API Key",
"route53SecretAccessKey": "Secret access key",
"gcdnsServiceAccountKey": "Service account key",
"digitalOceanToken": "DigitalOcean token",
diff --git a/dashboard/src/components/DomainProviderForm.vue b/dashboard/src/components/DomainProviderForm.vue
index e17fad14e..713010a3f 100644
--- a/dashboard/src/components/DomainProviderForm.vue
+++ b/dashboard/src/components/DomainProviderForm.vue
@@ -56,6 +56,7 @@ function needsPort80(dnsProvider, tlsProvider) {
function resetFields() {
dnsConfig.value.accessKeyId = '';
dnsConfig.value.accessKey = '';
+ dnsConfig.value.apiUrl = '';
dnsConfig.value.accessToken = '';
dnsConfig.value.apiKey = '';
dnsConfig.value.appKey = '';
@@ -134,6 +135,16 @@ function onGcdnsFileInputChange(event) {
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
+ <!-- powerdns -->
+ <FormGroup v-if="provider === 'powerdns'">
+ <label for="powerdnsApiUrlInput">{{ $t('domains.domainDialog.powerdnsApiUrl') }}</label>
+ <TextInput id="powerdnsApiUrlInput" type="url" v-model="dnsConfig.apiUrl" placeholder="https://ns1.example.com:8081" required />
+ </FormGroup>
+ <FormGroup v-if="provider === 'powerdns'">
+ <label for="powerdnsApiKeyInput">{{ $t('domains.domainDialog.powerdnsApiKey') }}</label>
+ <MaskedInput id="powerdnsApiKeyInput" v-model="dnsConfig.apiKey" required />
+ </FormGroup>
+
<!-- Route53 -->
<FormGroup v-if="provider === 'route53'">
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
diff --git a/dashboard/src/models/DomainsModel.js b/dashboard/src/models/DomainsModel.js
index f3c65a70a..8a4f452cd 100644
--- a/dashboard/src/models/DomainsModel.js
+++ b/dashboard/src/models/DomainsModel.js
@@ -23,6 +23,7 @@ const providers = [
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
+ { name: 'PowerDNS', value: 'powerdns' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
@@ -90,6 +91,9 @@ function filterConfigForProvider(provider, config) {
case 'porkbun':
props = ['apikey', 'secretapikey'];
break;
+ case 'powerdns':
+ props = ['apiUrl', 'apiKey'];
+ break;
}
const ret = {
diff --git a/src/dns.js b/src/dns.js
index 364f37af8..fde71cb72 100644
--- a/src/dns.js
+++ b/src/dns.js
@@ -34,6 +34,7 @@ import dnsNoop from './dns/noop.js';
import dnsManual from './dns/manual.js';
import dnsOvh from './dns/ovh.js';
import dnsPorkbun from './dns/porkbun.js';
+import dnsPowerdns from './dns/powerdns.js';
import dnsWildcard from './dns/wildcard.js';
const { log } = logger('dns');
@@ -45,7 +46,7 @@ const DNS_PROVIDERS = {
godaddy: dnsGodaddy, inwx: dnsInwx, linode: dnsLinode, vultr: dnsVultr,
namecom: dnsNamecom, namecheap: dnsNamecheap, netcup: dnsNetcup, hetzner: dnsHetzner,
hetznercloud: dnsHetznercloud, noop: dnsNoop, manual: dnsManual, ovh: dnsOvh,
- porkbun: dnsPorkbun, wildcard: dnsWildcard
+ porkbun: dnsPorkbun, powerdns: dnsPowerdns, wildcard: dnsWildcard
};
// choose which subdomain backend we use for test purpose we use route53
diff --git a/src/dns/powerdns.js b/src/dns/powerdns.js
new file mode 100644
index 000000000..a4f072ff7
--- /dev/null
+++ b/src/dns/powerdns.js
@@ -0,0 +1,165 @@
+import assert from 'node:assert';
+import BoxError from '../boxerror.js';
+import logger from '../logger.js';
+import dns from '../dns.js';
+import safe from '@cloudron/safetydance';
+import superagent from '@cloudron/superagent';
+import waitForDns from './waitfordns.js';
+
+const { log } = logger('dns/powerdns');
+
+function formatError(response) {
+ return `PowerDNS error ${response.status} ${response.body ? JSON.stringify(response.body) : response.text}`;
+}
+
+function removePrivateFields(domainObject) {
+ delete domainObject.config.apiKey;
+ return domainObject;
+}
+
+function injectPrivateFields(newConfig, currentConfig) {
+ if (!Object.hasOwn(newConfig, 'apiKey')) newConfig.apiKey = currentConfig.apiKey;
+}
+
+async function get(domainObject, subdomain, type) {
+ assert.strictEqual(typeof domainObject, 'object');
+ assert.strictEqual(typeof subdomain, 'string');
+ assert.strictEqual(typeof type, 'string');
+
+ const domainConfig = domainObject.config;
+ const baseUrl = domainConfig.apiUrl.replace(/\/$/, '');
+ const zoneName = domainObject.zoneName + '.';
+ const fqdn = dns.fqdn(subdomain, domainObject.domain) + '.';
+
+ log(`get: domain ${domainObject.domain} zone ${zoneName} fqdn ${fqdn} type ${type}`);
+
+ const [error, response] = await safe(superagent.get(`${baseUrl}/api/v1/servers/localhost/zones/${zoneName}`)
+ .set('X-API-Key', domainConfig.apiKey)
+ .timeout(30 * 1000)
+ .retry(5)
+ .ok(() => true));
+
+ if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
+ if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND, formatError(response));
+ if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
+ if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
+
+ const rrset = response.body.rrsets.find(r => r.name === fqdn && r.type === type);
+ if (!rrset) return [];
+
+ return rrset.records.map(r => {
+ if (type === 'TXT') return r.content.replace(/^"(.*)"$/, '$1');
+ return r.content;
+ });
+}
+
+async function upsert(domainObject, subdomain, type, values) {
+ assert.strictEqual(typeof domainObject, 'object');
+ assert.strictEqual(typeof subdomain, 'string');
+ assert.strictEqual(typeof type, 'string');
+ assert(Array.isArray(values));
+
+ const domainConfig = domainObject.config;
+ const baseUrl = domainConfig.apiUrl.replace(/\/$/, '');
+ const zoneName = domainObject.zoneName + '.';
+ const fqdn = dns.fqdn(subdomain, domainObject.domain) + '.';
+
+ log(`upsert: domain ${domainObject.domain} zone ${zoneName} fqdn ${fqdn} type ${type} values ${values}`);
+
+ const records = values.map(v => {
+ let content = v;
+ if (type === 'TXT' && !content.startsWith('"')) content = `"${v}"`;
+ return { content, disabled: false };
+ });
+
+ const rrset = {
+ name: fqdn,
+ type: type,
+ ttl: 60,
+ changetype: 'REPLACE',
+ records: records
+ };
+
+ const [error, response] = await safe(superagent.patch(`${baseUrl}/api/v1/servers/localhost/zones/${zoneName}`)
+ .set('X-API-Key', domainConfig.apiKey)
+ .send({ rrsets: [rrset] })
+ .timeout(30 * 1000)
+ .retry(5)
+ .ok(() => true));
+
+ if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
+ if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
+ if (response.status !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
+}
+
+async function del(domainObject, subdomain, type, values) {
+ assert.strictEqual(typeof domainObject, 'object');
+ assert.strictEqual(typeof subdomain, 'string');
+ assert.strictEqual(typeof type, 'string');
+ assert(Array.isArray(values));
+
+ const domainConfig = domainObject.config;
+ const baseUrl = domainConfig.apiUrl.replace(/\/$/, '');
+ const zoneName = domainObject.zoneName + '.';
+ const fqdn = dns.fqdn(subdomain, domainObject.domain) + '.';
+
+ log(`del: domain ${domainObject.domain} zone ${zoneName} fqdn ${fqdn} type ${type} values ${values}`);
+
+ const rrset = {
+ name: fqdn,
+ type: type,
+ changetype: 'DELETE'
+ };
+
+ const [error, response] = await safe(superagent.patch(`${baseUrl}/api/v1/servers/localhost/zones/${zoneName}`)
+ .set('X-API-Key', domainConfig.apiKey)
+ .send({ rrsets: [rrset] })
+ .timeout(30 * 1000)
+ .retry(5)
+ .ok(() => true));
+
+ if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
+ if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
+ if (response.status === 404 || response.status === 422) return;
+ if (response.status !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
+}
+
+async function wait(domainObject, subdomain, type, value, options) {
+ assert.strictEqual(typeof domainObject, 'object');
+ assert.strictEqual(typeof subdomain, 'string');
+ assert.strictEqual(typeof type, 'string');
+ assert.strictEqual(typeof value, 'string');
+ assert(options && typeof options === 'object');
+
+ const fqdn = dns.fqdn(subdomain, domainObject.domain);
+ await waitForDns(fqdn, domainObject.zoneName, type, value, options);
+}
+
+async function verifyDomainConfig(domainObject) {
+ assert.strictEqual(typeof domainObject, 'object');
+
+ const domainConfig = domainObject.config;
+ if (!domainConfig.apiUrl || typeof domainConfig.apiUrl !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'apiUrl must be a non-empty string');
+ if (!domainConfig.apiKey || typeof domainConfig.apiKey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'apiKey must be a non-empty string');
+
+ const testSubdomain = 'cloudrontestdns';
+ const testIp = '127.0.0.1';
+
+ await upsert(domainObject, testSubdomain, 'A', [testIp]);
+ await del(domainObject, testSubdomain, 'A', [testIp]);
+
+ return {
+ apiUrl: domainConfig.apiUrl,
+ apiKey: domainConfig.apiKey
+ };
+}
+
+export default {
+ removePrivateFields,
+ injectPrivateFields,
+ upsert,
+ get,
+ del,
+ wait,
+ verifyDomainConfig
+};
diff --git a/src/test/dns-providers-test.js b/src/test/dns-providers-test.js
index 417a2a9ab..454f9803b 100644
--- a/src/test/dns-providers-test.js
+++ b/src/test/dns-providers-test.js
@@ -412,6 +412,79 @@ describe('dns provider', function () {
const TOKEN = 'sometoken';
const NAMECOM_API = 'https://api.name.com/v4';
+
+ describe('powerdns', function () {
+ const API_URL = 'http://ns1.example.com:8081';
+ const API_KEY = 'secret';
+
+ before(async function () {
+ domainCopy.provider = 'powerdns';
+ domainCopy.config = {
+ apiUrl: API_URL,
+ apiKey: API_KEY
+ };
+
+ await domains.setConfig(domainCopy.domain, domainCopy, auditSource);
+ });
+
+ it('upsert non-existing record succeeds', async function () {
+ nock.cleanAll();
+
+ const zoneName = domainCopy.zoneName + '.';
+ const fqdn = 'test.' + domainCopy.domain + '.';
+
+ const req1 = nock(API_URL)
+ .patch('/api/v1/servers/localhost/zones/' + zoneName, body => {
+ return body.rrsets[0].name === fqdn &&
+ body.rrsets[0].type === 'A' &&
+ body.rrsets[0].changetype === 'REPLACE' &&
+ body.rrsets[0].records[0].content === '1.2.3.4';
+ })
+ .reply(204);
+
+ await dns.upsertDnsRecords('test', domainCopy.domain, 'A', ['1.2.3.4']);
+ assert.ok(req1.isDone());
+ });
+
+ it('get succeeds', async function () {
+ nock.cleanAll();
+
+ const zoneName = domainCopy.zoneName + '.';
+ const fqdn = 'test.' + domainCopy.domain + '.';
+
+ const req1 = nock(API_URL)
+ .get('/api/v1/servers/localhost/zones/' + zoneName)
+ .reply(200, {
+ rrsets: [{
+ name: fqdn,
+ type: 'A',
+ records: [{ content: '1.2.3.4', disabled: false }]
+ }]
+ });
+
+ const result = await dns.getDnsRecords('test', domainCopy.domain, 'A');
+ assert.deepEqual(result, ['1.2.3.4']);
+ assert.ok(req1.isDone());
+ });
+
+ it('del succeeds', async function () {
+ nock.cleanAll();
+
+ const zoneName = domainCopy.zoneName + '.';
+ const fqdn = 'test.' + domainCopy.domain + '.';
+
+ const req1 = nock(API_URL)
+ .patch('/api/v1/servers/localhost/zones/' + zoneName, body => {
+ return body.rrsets[0].name === fqdn &&
+ body.rrsets[0].type === 'A' &&
+ body.rrsets[0].changetype === 'DELETE';
+ })
+ .reply(204);
+
+ await dns.removeDnsRecords('test', domainCopy.domain, 'A', ['1.2.3.4']);
+ assert.ok(req1.isDone());
+ });
+ });
before(async function () {
domainCopy.provider = 'namecom';
domainCopy.config = {