diff --git a/CHANGES b/CHANGES index 837904e85..18f9edbdf 100644 --- a/CHANGES +++ b/CHANGES @@ -2623,3 +2623,5 @@ * notifications: email configuration error shown incorrectly * OpenID: add RSA-SHA256 signature algorithm +[7.5.0] +* dns: Add Bunny diff --git a/dashboard/src/js/setupdns.js b/dashboard/src/js/setupdns.js index 1857989b2..8dfacc0b9 100644 --- a/dashboard/src/js/setupdns.js +++ b/dashboard/src/js/setupdns.js @@ -79,6 +79,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f // keep in sync with domains.js $scope.dnsProvider = [ { name: 'AWS Route53', value: 'route53' }, + { name: 'Bunny', value: 'bunny' }, { name: 'Cloudflare', value: 'cloudflare' }, { name: 'DigitalOcean', value: 'digitalocean' }, { name: 'Gandi LiveDNS', value: 'gandi' }, @@ -110,6 +111,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f godaddyApiKey: '', godaddyApiSecret: '', linodeToken: '', + bunnyAccessKey: '', hetznerToken: '', vultrToken: '', nameComUsername: '', @@ -200,6 +202,8 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f config.defaultProxyStatus = $scope.dnsCredentials.cloudflareDefaultProxyStatus; } else if (provider === 'linode') { config.token = $scope.dnsCredentials.linodeToken; + } else if (provider === 'bunny') { + config.token = $scope.dnsCredentials.bunnyAccessKey; } else if (provider === 'hetzner') { config.token = $scope.dnsCredentials.hetznerToken; } else if (provider === 'vultr') { diff --git a/dashboard/src/setupdns.html b/dashboard/src/setupdns.html index 2ba0f3468..faedfff38 100644 --- a/dashboard/src/setupdns.html +++ b/dashboard/src/setupdns.html @@ -220,6 +220,12 @@

+ +

+ + +

+
diff --git a/dashboard/src/translation/en.json b/dashboard/src/translation/en.json index b4763697a..347895a8b 100644 --- a/dashboard/src/translation/en.json +++ b/dashboard/src/translation/en.json @@ -1034,7 +1034,8 @@ "hetznerToken": "Hetzner Token", "cloudflareDefaultProxyStatus": "Enable proxying for new DNS records", "porkbunApikey": "API Key", - "porkbunSecretapikey": "Secret API Key" + "porkbunSecretapikey": "Secret API Key", + "bunnyAccessKey": "Bunny Access Key" }, "removeDialog": { "title": "Really remove {{ domain }}?", @@ -1782,7 +1783,8 @@ "zh_Hans": "Chinese (Simplified)", "es": "Spanish", "ru": "Russian", - "pt": "Portuguese" + "pt": "Portuguese", + "da": "Danish" }, "volumes": { "title": "Volumes", diff --git a/dashboard/src/translation/nl.json b/dashboard/src/translation/nl.json index 860677eb9..bd94c179e 100644 --- a/dashboard/src/translation/nl.json +++ b/dashboard/src/translation/nl.json @@ -1775,7 +1775,8 @@ "pl": "Pools", "es": "Spaans", "ru": "Russisch", - "pt": "Portugees" + "pt": "Portugees", + "da": "Deens" }, "passwordResetEmail": { "subject": "[<%= cloudron %>] Wachtwoord herstellen", diff --git a/dashboard/src/views/domains.html b/dashboard/src/views/domains.html index 2515d1e91..d41617957 100644 --- a/dashboard/src/views/domains.html +++ b/dashboard/src/views/domains.html @@ -140,6 +140,12 @@
+ +
+ + +
+
diff --git a/dashboard/src/views/domains.js b/dashboard/src/views/domains.js index e42fc2013..7b1287855 100644 --- a/dashboard/src/views/domains.js +++ b/dashboard/src/views/domains.js @@ -44,6 +44,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat // keep in sync with setupdns.js $scope.dnsProvider = [ { name: 'AWS Route53', value: 'route53' }, + { name: 'Bunny', value: 'bunny' }, { name: 'Cloudflare', value: 'cloudflare' }, { name: 'DigitalOcean', value: 'digitalocean' }, { name: 'Gandi LiveDNS', value: 'gandi' }, @@ -63,6 +64,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.prettyProviderName = function (domain) { switch (domain.provider) { + case 'bunny': return 'Bunny'; case 'route53': return 'AWS Route53'; case 'cloudflare': return 'Cloudflare'; case 'digitalocean': return 'DigitalOcean'; @@ -246,6 +248,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat cloudflareDefaultProxyStatus: false, cloudflareTokenType: 'GlobalApiKey', linodeToken: '', + bunnyAccessKey: '', hetznerToken: '', vultrToken: '', nameComToken: '', @@ -303,6 +306,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat } $scope.domainConfigure.digitalOceanToken = domain.provider === 'digitalocean' ? domain.config.token : ''; $scope.domainConfigure.linodeToken = domain.provider === 'linode' ? domain.config.token : ''; + $scope.domainConfigure.bunnyAccessKey = domain.provider === 'bunny' ? domain.config.accessKey : ''; $scope.domainConfigure.hetznerToken = domain.provider === 'hetzner' ? domain.config.token : ''; $scope.domainConfigure.vultrToken = domain.provider === 'vultr' ? domain.config.token : ''; $scope.domainConfigure.gandiApiKey = domain.provider === 'gandi' ? domain.config.token : ''; @@ -373,6 +377,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat data.token = $scope.domainConfigure.digitalOceanToken; } else if (provider === 'linode') { data.token = $scope.domainConfigure.linodeToken; + } else if (provider === 'bunny') { + data.accessKey = $scope.domainConfigure.bunnyAccessKey; } else if (provider === 'hetzner') { data.token = $scope.domainConfigure.hetznerToken; } else if (provider === 'vultr') { diff --git a/src/dns.js b/src/dns.js index a36a546c5..3746effa0 100644 --- a/src/dns.js +++ b/src/dns.js @@ -40,6 +40,7 @@ function api(provider) { assert.strictEqual(typeof provider, 'string'); switch (provider) { + case 'bunny': return require('./dns/bunny.js'); case 'cloudflare': return require('./dns/cloudflare.js'); case 'route53': return require('./dns/route53.js'); case 'gcdns': return require('./dns/gcdns.js'); diff --git a/src/dns/bunny.js b/src/dns/bunny.js new file mode 100644 index 000000000..603df6a8e --- /dev/null +++ b/src/dns/bunny.js @@ -0,0 +1,254 @@ +'use strict'; + +exports = module.exports = { + removePrivateFields, + injectPrivateFields, + upsert, + get, + del, + wait, + verifyDomainConfig +}; + +const assert = require('assert'), + BoxError = require('../boxerror.js'), + constants = require('../constants.js'), + debug = require('debug')('box:dns/bunny'), + dig = require('../dig.js'), + dns = require('../dns.js'), + safe = require('safetydance'), + superagent = require('superagent'), + waitForDns = require('./waitfordns.js'); + +const BUNNY_API = 'https://api.bunny.net'; +const RECORD_TYPES = [ 'A', 'AAAA', 'CNAME', 'TXT', 'MX', 'RDR', '???', 'PZ', 'SRV', 'CAA', 'PTR', 'SCR', 'NS' ]; + +function recordTypeToString(value) { + return RECORD_TYPES[value]; +} + +function recordTypeToInt(value) { + return RECORD_TYPES.indexOf(value); +} + +function formatError(response) { + return `Bunny DNS error ${response.statusCode} ${JSON.stringify(response.body)}`; +} + +function removePrivateFields(domainObject) { + domainObject.config.accessKey = constants.SECRET_PLACEHOLDER; + return domainObject; +} + +function injectPrivateFields(newConfig, currentConfig) { + if (newConfig.accessKey === constants.SECRET_PLACEHOLDER) newConfig.accessKey = currentConfig.accessKey; +} + +async function getZoneId(domainConfig, zoneName) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + + const [error, response] = await safe(superagent.get(`${BUNNY_API}/dnszone?page=1&perPage=1000&search=${zoneName}`) + .set('AccessKey', domainConfig.accessKey) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + if (!Array.isArray(response.body.Items)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid records in response: ${JSON.stringify(response.body)}`); + const item = response.body.Items.filter(item => item.Domain === zoneName); + if (item.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found'); + + return item[0].Id; +} + +async function getDnsRecords(domainConfig, zoneName, name, type) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof type, 'string'); + + debug(`get: ${name} in zone ${zoneName} of type ${type}`); + + const zoneId = await getZoneId(domainConfig, zoneName); + const [error, response] = await safe(superagent.get(`${BUNNY_API}/dnszone/${zoneId}`) + .set('AccessKey', domainConfig.accessKey) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + if (!Array.isArray(response.body.Records)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid records in response: ${JSON.stringify(response.body)}`); + + return response.body.Records.filter(r => recordTypeToString(r.Type) === type && r.Name === name); +} + +async function upsert(domainObject, location, type, values) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + assert(Array.isArray(values)); + + const domainConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = dns.getName(domainObject, location, type) || ''; + + debug(`upsert: ${name} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`); + + const zoneId = await getZoneId(domainConfig, zoneName); + const records = await getDnsRecords(domainConfig, zoneName, name, type); + + // used to track available records to update instead of create + let i = 0, recordIds = []; + + for (let value of values) { + let priority = 0; + + if (type === 'MX') { + priority = parseInt(value.split(' ')[0], 10); + value = value.split(' ')[1]; + } else if (type === 'TXT') { + value = value.replace(/^"(.*)"$/, '$1'); // strip any double quotes + } + + const data = { + Type: recordTypeToInt(type), + Name: name, + Value: value, + Priority: priority, + Ttl: 10 + }; + + if (i >= records.length) { + const [error, response] = await safe(superagent.put(`${BUNNY_API}/dnszone/${zoneId}/records`) + .set('AccessKey', domainConfig.accessKey) + .send(data) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + } else { + const [error, response] = await safe(superagent.post(`${BUNNY_API}/dnszone/${zoneId}/records/${records[i].Id}`) + .set('AccessKey', domainConfig.accessKey) + .send(data) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + ++i; + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + recordIds.push(safe.query(records.body, 'domain_record.id')); + } + } + + for (let j = values.length + 1; j < records.length; j++) { + const [error] = await safe(superagent.del(`${BUNNY_API}/dnszone/${zoneId}/records/${records[j].Id}`) + .set('AccessKey', domainConfig.accessKey) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + + if (error) debug(`upsert: error removing record ${records[j].id}: ${error.message}`); + } + + debug('upsert: completed with recordIds:%j', recordIds); +} + +async function get(domainObject, location, type) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + + const domainConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = dns.getName(domainObject, location, type) || ''; + + const records = await getDnsRecords(domainConfig, zoneName, name, type); + return records.map(r => r.Value); +} + +async function del(domainObject, location, type, values) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + assert(Array.isArray(values)); + + const domainConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = dns.getName(domainObject, location, type) || ''; + + debug(`del: ${name} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`); + + const zoneId = await getZoneId(domainConfig, zoneName); + const records = await getDnsRecords(domainConfig, zoneName, name, type); + const ids = records.filter(r => values.includes(r.Value)).map(r => r.Id); + + for (const id of ids) { + const [error, response] = await safe(superagent.del(`${BUNNY_API}/dnszone/${zoneId}/records/${id}`) + .set('AccessKey', domainConfig.accessKey) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode === 400) continue; + if (response.statusCode !== 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'); // { interval: 5000, times: 50000 } + + 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, + zoneName = domainObject.zoneName; + + if (!domainConfig.accessKey || typeof domainConfig.accessKey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'accessKey must be a non-empty string'); + + const ip = '127.0.0.1'; + + const credentials = { + accessKey: domainConfig.accessKey, + }; + + if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here + + const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 })); + if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain'); + if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers'); + + if (!nameservers.every(function (n) { return n.toLowerCase().indexOf('.bunny.net') !== -1; })) { + debug('verifyDomainConfig: %j does not contain Bunny NS', nameservers); + throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Bunny'); + } + + const location = 'cloudrontestdns'; + + await upsert(domainObject, location, 'A', [ ip ]); + debug('verifyDomainConfig: Test A record added'); + + await del(domainObject, location, 'A', [ ip ]); + debug('verifyDomainConfig: Test A record removed again'); + + return credentials; +} diff --git a/src/domains.js b/src/domains.js index ba063ce01..4ee106db8 100644 --- a/src/domains.js +++ b/src/domains.js @@ -51,6 +51,7 @@ function api(provider) { assert.strictEqual(typeof provider, 'string'); switch (provider) { + case 'bunny': return require('./dns/bunny.js'); case 'cloudflare': return require('./dns/cloudflare.js'); case 'route53': return require('./dns/route53.js'); case 'gcdns': return require('./dns/gcdns.js');