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) {
+ + + + + + + + + + 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/package-lock.json b/package-lock.json index 580d751c1..af73ce2c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -364,7 +364,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz", "integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -1229,7 +1228,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1268,7 +1266,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -3133,7 +3130,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3222,7 +3218,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3379,7 +3374,6 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -4573,7 +4567,6 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6197,7 +6190,6 @@ "resolved": "https://registry.npmjs.org/koa/-/koa-3.1.1.tgz", "integrity": "sha512-KDDuvpfqSK0ZKEO2gCPedNjl5wYpfj+HNiuVRlbhd1A88S3M0ySkdf2V/EJ4NWt5dwh5PXCdcenrKK2IQJAxsg==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^1.3.8", "content-disposition": "~0.5.4", 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 = {