Support PowerDNS Provider
-
Since I've run a PowerDNS Authoritative DNS for quite a while now, I've been interested in this feature as well. Cloudron not supporting this is the only thing that has prevented me to move my cloudron-managed domains away from cloud providers.
I've made several private integrations with the API in the past, but Javascript has held me back from trying my hand at this for Cloudron. Now, with the help of an LLM, I probably have a working implementation. At least it looks good from the API calls perspective.
So I have two questions for staff:
-
How can I test it (preferably on a fresh VM, so as to not put my production Cloudron in jeopardy)? I could not really find build/test/deploy instructions in the box repo.
-
Would you be interested in this implementation if it is tested and works? If you are, I fully intend to give you this code under the terms of your license as show in the box repo. It's in a private repo for now since I seem to have lost the account I once had on git.cloudron.io.
Thank you in advance!
-
-
Since I've run a PowerDNS Authoritative DNS for quite a while now, I've been interested in this feature as well. Cloudron not supporting this is the only thing that has prevented me to move my cloudron-managed domains away from cloud providers.
I've made several private integrations with the API in the past, but Javascript has held me back from trying my hand at this for Cloudron. Now, with the help of an LLM, I probably have a working implementation. At least it looks good from the API calls perspective.
So I have two questions for staff:
-
How can I test it (preferably on a fresh VM, so as to not put my production Cloudron in jeopardy)? I could not really find build/test/deploy instructions in the box repo.
-
Would you be interested in this implementation if it is tested and works? If you are, I fully intend to give you this code under the terms of your license as show in the box repo. It's in a private repo for now since I seem to have lost the account I once had on git.cloudron.io.
Thank you in advance!
Hello @jk
How can I test it (preferably on a fresh VM, so as to not put my production Cloudron in jeopardy)? I could not really find build/test/deploy instructions in the box repo.
It is possible.
You can always edit the code directly in/home/yellowtent/box/and after the edit you need to restart thebox.servicewithsystemctl restart box.service
But this should really only be done a separate Cloudron server since the risk of causing a full system defect is given.Would you be interested in this implementation if it is tested and works? If you are, I fully intend to give you this code under the terms of your license as show in the box repo. It's in a private repo for now since I seem to have lost the account I once had on git.cloudron.io .
Yes, if you have some working code and would like it getting added to Cloudron you need to share the code.
-
-
Hello @jk
How can I test it (preferably on a fresh VM, so as to not put my production Cloudron in jeopardy)? I could not really find build/test/deploy instructions in the box repo.
It is possible.
You can always edit the code directly in/home/yellowtent/box/and after the edit you need to restart thebox.servicewithsystemctl restart box.service
But this should really only be done a separate Cloudron server since the risk of causing a full system defect is given.Would you be interested in this implementation if it is tested and works? If you are, I fully intend to give you this code under the terms of your license as show in the box repo. It's in a private repo for now since I seem to have lost the account I once had on git.cloudron.io .
Yes, if you have some working code and would like it getting added to Cloudron you need to share the code.
You can always edit the code directly in /home/yellowtent/box/ and after the edit you need to restart the box.service with systemctl restart box.service
Yes, I've done that in the past to take care of some tiny issues with the proxy.
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.
-
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