Support PowerDNS Provider
-
Hello @jk
Would this also automatically work with changes to the Vue code? Because that's usually compiled right? Part of the patch is adding support for PowerDNS in the UI.
That is true.
Since the whole process of patching Cloudron fully to test code is rather complex, can you provide your changes in a merge request so we can have a look? -
Hello @jk
Would this also automatically work with changes to the Vue code? Because that's usually compiled right? Part of the patch is adding support for PowerDNS in the UI.
That is true.
Since the whole process of patching Cloudron fully to test code is rather complex, can you provide your changes in a merge request so we can have a look? -
Hello @jk
We have sent you an invitation.
I am unsure about the permission set up for the box repository.
If possible push your changes in your own branch directly in the repository, and we will create the PR.
If not possible, please fork the repository and push your changes in a separate branch in your fork, and we will create the PR. -
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: JK <redacted@redacted> 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 = {
Hello! It looks like you're interested in this conversation, but you don't have an account yet.
Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, and choose to be notified of new replies (either via email, or push notification). You'll also be able to save bookmarks and upvote posts to show your appreciation to other community members.
With your input, this post could be even better 💗
Register Login