Cloudron makes it easy to run web apps like WordPress, Nextcloud, GitLab on your server. Find out more or install now.


Skip to content
  • What's coming in 9.2

    Pinned Announcements
    7
    9 Votes
    7 Posts
    154 Views
    avatar1024A
    Also given you're looking at emails, I wonder if you might have time to look at a couple more email related ideas (but I understand new features aren't ad shouldn't be the priority over what you've listed): Introducing manual / basic moderation for external mailing list (i.e. setting one email address / mailing list member who receives emails from external people and can decide or not to forward manually them to the list), like described here Creating mailing list based on a group, so that mailing list members / email address get automatically updated when users enter / leave groups Currently when I send an email to a maling list I'm part of, I also receive that email. Would it be possible to have a (mailing list wide) option so that is not the case, i.e. that senders don't receive their own email if they are aprt of the list?
  • public view of calendar(s)

    Calendar
    1
    0 Votes
    1 Posts
    1 Views
    No one has replied
  • Support PowerDNS Provider

    Feature Requests
    11
    4 Votes
    11 Posts
    744 Views
    J
    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 = {
  • OpenClaw

    Community Apps
    16
    1 Votes
    16 Posts
    535 Views
    andreasduerenA
    @jdaviescoates maybe. But honestly I’ve been running Ente fully automatically updated with CI/CD for a year? now and never had any issues whatsoever.
  • Password auth for SSHFS

    Feature Requests
    2
    2 Votes
    2 Posts
    10 Views
    nebulonN
    yes for the moment you can create a manual mount yourself and then configure it in Cloudron with the filesystem (mount point) provider. That one ensures that in case the server lost the mount on say reboot, it does not backup to the local disk
  • 1 Votes
    8 Posts
    59 Views
    nebulonN
    But it is valuable information at least that apparently the app needs to be updated one version after the other.
  • Mattermost - Package Updates

    Pinned Mattermost
    199
    0 Votes
    199 Posts
    157k Views
    Package UpdatesP
    [1.26.1] Update mattermost to 11.5.2 Full Changelog Mattermost Platform Release 11.5.2 contains medium to high severity level security fixes.
  • Calendar doesn't show up in the app password dropdown

    Calendar
    3
    2 Votes
    3 Posts
    8 Views
    nebulonN
    Forgot to mention, this is already fixed with https://git.cloudron.io/platform/box/-/commit/053f26cd02ccc0b5b840e7bf358fe1d47e1e4b75 but pending the 9.2 release
  • Jitsi unlisted from app store

    Pinned Jitsi
    13
    6 Votes
    13 Posts
    3k Views
    jamesJ
    Hello @matthiaskurz That is good to read. If you find anything odd with MiroTalk, the maintainer is very active in the forum and is always happy to help. So if you have an issue, just create a topic in the @mirotalk-57bab571 category and the maintainer @mirotalk should see it.
  • 8 Votes
    10 Posts
    2k Views
    jamesJ
    Hello @levionic and welcome to the Cloudron Forum
  • Port 25 inbound connection timeout and missing email

    Unsolved Support email performance spamassassin
    10
    1 Votes
    10 Posts
    286 Views
    jamesJ
    Hello @p44 That is an interesting find. Could save the logs of the mail service when it was failing somewhere so we can request it later from you?
  • Open Source DNS/Nameserver App?

    App Wishlist
    6
    0 Votes
    6 Posts
    2k Views
    jdaviescoatesJ
    There is also all the code that powers https://desec.io/ here https://github.com/desec-io/desec-stack
  • WBO - Package Updates

    Pinned WBO
    42
    1 Votes
    42 Posts
    11k Views
    Package UpdatesP
    [1.25.0] Update whitebophir to 1.29.0 Full Changelog
  • InvoiceNinja - Package Updates

    Pinned Invoice Ninja
    573
    0 Votes
    573 Posts
    938k Views
    Package UpdatesP
    [1.22.14] Update invoiceninja to 5.13.18 Full Changelog Security enhancements for company logos Updated dependencies Ensure model reguards are applied across all jobs Add net costs to Product Columns for Invoice PDFs
  • Docmost - Package Updates

    Pinned Docmost
    7
    0 Votes
    7 Posts
    226 Views
    Package UpdatesP
    [1.1.0] Update docmost to 0.80.0 Full Changelog feat: watch space by @​Philipinho in https://github.com/docmost/docmost/pull/2096 feat(ee): ai chat by @​Philipinho in https://github.com/docmost/docmost/pull/2098 feat: favorites by @​Philipinho in https://github.com/docmost/docmost/pull/2103 feat(ee): page verification workflow by @​Philipinho in https://github.com/docmost/docmost/pull/2102 feat: enhancements by @​Philipinho in https://github.com/docmost/docmost/pull/2107 fix home flickers by @​Philipinho in https://github.com/docmost/docmost/pull/2108 fix: space overview favorites by @​Philipinho in https://github.com/docmost/docmost/pull/2110 feat(ee): PDF export api by @​Philipinho in https://github.com/docmost/docmost/pull/2112
  • FreeScout - Package Updates

    Pinned FreeScout
    269
    0 Votes
    269 Posts
    303k Views
    Package UpdatesP
    [1.16.6] Update freescout to 1.8.215 Full Changelog Check file paths in Zipper before extracting files (Security) Add csrf_token to OAuth Disconnect link (Security) Sanitize $attachments_to_remove when deleting attachments (Security) Check permissions when setting chat_start_new for a mailbox (Security) Check permissions for assigned-only users when editing drafts (Security) Check permissions when assigned-only user is editing customer message (Security) Fixed compact() - Undefined variable operator (#​5308) For assigned-only users show only assigned conversations in the Search (Security) Make conversation Unassigned when moving it if its assignee does not have access to the target mailbox (#​5333) Fix error on creating a user (#​5337)
  • Baserow - Package Updates

    Pinned Baserow
    104
    2 Votes
    104 Posts
    39k Views
    Package UpdatesP
    [1.37.0] Update baserow to 2.2.0 Full Changelog [Database] Add the array_unique formula function to deduplicate lookup arrays. #​2326 [Builder] Allow to drag and drop element on the page #​3634 [Database] Introduced restricted view ownership type for view-level permissions. #​3673 [Automation] Support automation templates #​3871 [Core] Improved Baserow formula function argument validation. #​4532 [Database] Add the array_slice formula function to extract sub-arrays from lookup arrays. #​5053 [Database] Add instance wide data scanner. [Database] Introduced field type that can edit a row via a form view. #​2287 [Database] Allow freezing (pinning) up to 4 columns on the left side of the grid view. #​2047 [Database] Generalize the index formula to work with any array type (not just file fields) and add first and last convenience functions. #​5065
  • Tandoor - Package Updates

    Pinned Tandoor
    74
    0 Votes
    74 Posts
    22k Views
    Package UpdatesP
    [1.12.7] Update recipes to 2.6.7 Full Changelog improved app importer error messages fixed error while search in slow network conditions #​4621 fixed stored XSS issues in the templating engine
  • Metabase - Package Updates

    Pinned Metabase
    537
    1 Votes
    537 Posts
    470k Views
    Package UpdatesP
    [3.5.4] Update metabase to 0.59.6.5 Full Changelog
  • Vault - Package Updates

    Pinned Vault
    96
    0 Votes
    96 Posts
    49k Views
    Package UpdatesP
    [1.83.0] Update vault to 2.0.0 Full Changelog PKI External CA (Enterprise): A new plugin that provides the ability to acquire PKI certificates from Public CA providers through the ACME protocol IBM PAO License Integration: Added IBM PAO license support, allowing usage of Vault Enterprise with an IBM PAO license key. A new configuration stanza license_entitlement is required in the Vault config to use an IBM license. For more details, see the License documentation. KMIP Bring Your Own CA: Add new API to manage multiple CAs for client verification and make it possible to import external CAs. LDAP Secrets Engine Enterprise Plugin: Add the new LDAP Secrets Engine Enterprise plugin. This enterprise version adds support for self-managed static roles and Rotation Manager support for automatic static role rotation. New plugin configurations can be set as "self managed", skipping the requirement for a bindpass field and allowing static roles to use their own password to rotate their credential. Automated static role credential rotation supports fine-grained scheduled rotations and retry policies through Vault Enterprise. Login MFA TOTP Self-Enrollment (Enterprise): Simplify creation of login MFA TOTP credentials for users, allowing them to self-enroll MFA TOTP using a QR code (TOTP secret) generated during login. The new functionality is configurable on the TOTP login MFA method configuration screen and via the enable_self_enrollment parameter in the API. Plugins (Enterprise): Allow overriding pinned version when creating and updating database engines Plugins (Enterprise): Allow overriding pinned version when enabling and tuning auth and secrets backends Template Integration for PublicPKICA: Vault Agent templates are now automatically re-rendered when a PKI external CA certificate is issued or renewed.