diff --git a/CHANGES b/CHANGES index d3be0d452..2bb41a3bc 100644 --- a/CHANGES +++ b/CHANGES @@ -2781,4 +2781,5 @@ * cifs: enable seal encryption by default * updatechecker: fix bug where release info was not refreshed * ovh: storage location domain has changed +* domains: add deSEC integration diff --git a/dashboard/src/js/setupdns.js b/dashboard/src/js/setupdns.js index 4097bdf3e..ef9f7b6a4 100644 --- a/dashboard/src/js/setupdns.js +++ b/dashboard/src/js/setupdns.js @@ -62,6 +62,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f { name: 'AWS Route53', value: 'route53' }, { name: 'Bunny', value: 'bunny' }, { name: 'Cloudflare', value: 'cloudflare' }, + { name: 'deSEC', value: 'desec' }, { name: 'DigitalOcean', value: 'digitalocean' }, { name: 'DNSimple', value: 'dnsimple' }, { name: 'Gandi LiveDNS', value: 'gandi' }, @@ -98,6 +99,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f dnsimpleAccessToken: '', hetznerToken: '', vultrToken: '', + deSecToken: '', nameComUsername: '', nameComToken: '', namecheapUsername: '', @@ -198,6 +200,8 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f config.token = $scope.dnsCredentials.hetznerToken; } else if (provider === 'vultr') { config.token = $scope.dnsCredentials.vultrToken; + } else if (provider === 'desec') { + config.token = $scope.dnsCredentials.deSecToken; } else if (provider === 'namecom') { config.username = $scope.dnsCredentials.nameComUsername; config.token = $scope.dnsCredentials.nameComToken; diff --git a/dashboard/src/setupdns.html b/dashboard/src/setupdns.html index 3cd174947..ad374d284 100644 --- a/dashboard/src/setupdns.html +++ b/dashboard/src/setupdns.html @@ -270,6 +270,12 @@

+ +

+ + +

+

Set up A records for *.{{ dnsCredentials.domain || 'example.com' }}. and {{ dnsCredentials.domain || 'example.com' }}. to this server's IP. diff --git a/dashboard/src/translation/en.json b/dashboard/src/translation/en.json index af0d94815..0a05ffeb0 100644 --- a/dashboard/src/translation/en.json +++ b/dashboard/src/translation/en.json @@ -1081,7 +1081,8 @@ "ovhEndpoint": "Endpoint", "ovhConsumerKey": "Consumer Key", "ovhAppKey": "Application Key", - "ovhAppSecret": "Application Secret" + "ovhAppSecret": "Application Secret", + "deSecToken": "deSEC Token" }, "removeDialog": { "title": "Really remove {{ domain }}?", diff --git a/dashboard/src/translation/nl.json b/dashboard/src/translation/nl.json index 98ba519c8..a2e8758a9 100644 --- a/dashboard/src/translation/nl.json +++ b/dashboard/src/translation/nl.json @@ -1786,7 +1786,8 @@ "2faToken": "2FA Token (indien ingeschakeld)", "errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord", "errorIncorrect2FAToken": "2FA token is niet geldig", - "errorInternal": "Interne fout, probeer later opnieuw" + "errorInternal": "Interne fout, probeer later opnieuw", + "loginWith": "Login met Cloudron" }, "passwordReset": { "title": "Wachtwoord herstellen", diff --git a/dashboard/src/views/domains.html b/dashboard/src/views/domains.html index 04819eb1e..091e65510 100644 --- a/dashboard/src/views/domains.html +++ b/dashboard/src/views/domains.html @@ -183,6 +183,12 @@ + +

+ + +
+
diff --git a/dashboard/src/views/domains.js b/dashboard/src/views/domains.js index 074cf8e79..c0ba9a298 100644 --- a/dashboard/src/views/domains.js +++ b/dashboard/src/views/domains.js @@ -46,6 +46,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat { name: 'AWS Route53', value: 'route53' }, { name: 'Bunny', value: 'bunny' }, { name: 'Cloudflare', value: 'cloudflare' }, + { name: 'deSEC', value: 'desec' }, { name: 'DigitalOcean', value: 'digitalocean' }, { name: 'DNSimple', value: 'dnsimple' }, { name: 'Gandi LiveDNS', value: 'gandi' }, @@ -69,6 +70,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat case 'bunny': return 'Bunny'; case 'route53': return 'AWS Route53'; case 'cloudflare': return 'Cloudflare'; + case 'desec': return 'deSEC'; case 'digitalocean': return 'DigitalOcean'; case 'dnsimple': return 'dnsimple'; case 'gandi': return 'Gandi LiveDNS'; @@ -258,6 +260,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat dnsimpleAccessToken: '', hetznerToken: '', vultrToken: '', + deSecToken: '', nameComToken: '', nameComUsername: '', namecheapUsername: '', @@ -321,6 +324,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.domainConfigure.dnsimpleAccessToken = domain.provider === 'dnsimple' ? domain.config.accessToken : ''; $scope.domainConfigure.hetznerToken = domain.provider === 'hetzner' ? domain.config.token : ''; $scope.domainConfigure.vultrToken = domain.provider === 'vultr' ? domain.config.token : ''; + $scope.domainConfigure.deSecToken = domain.provider === 'desec' ? domain.config.token : ''; $scope.domainConfigure.gandiApiKey = domain.provider === 'gandi' ? domain.config.token : ''; $scope.domainConfigure.cloudflareToken = domain.provider === 'cloudflare' ? domain.config.token : ''; $scope.domainConfigure.cloudflareEmail = domain.provider === 'cloudflare' ? domain.config.email : ''; @@ -402,6 +406,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat data.token = $scope.domainConfigure.hetznerToken; } else if (provider === 'vultr') { data.token = $scope.domainConfigure.vultrToken; + } else if (provider === 'desec') { + data.token = $scope.domainConfigure.deSecToken; } else if (provider === 'gandi') { data.token = $scope.domainConfigure.gandiApiKey; } else if (provider === 'godaddy') { @@ -503,6 +509,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.domainConfigure.porkbunApikey = ''; $scope.domainConfigure.porkbunSecretapikey = ''; $scope.domainConfigure.vultrToken = ''; + $scope.domainConfigure.deSecToken = ''; $scope.domainConfigure.tlsConfig.provider = 'letsencrypt-prod'; $scope.domainConfigure.zoneName = ''; diff --git a/src/dns.js b/src/dns.js index 569d80469..a4017d3ea 100644 --- a/src/dns.js +++ b/src/dns.js @@ -46,6 +46,7 @@ function api(provider) { switch (provider) { case 'bunny': return require('./dns/bunny.js'); case 'cloudflare': return require('./dns/cloudflare.js'); + case 'desec': return require('./dns/desec.js'); case 'dnsimple': return require('./dns/dnsimple.js'); case 'route53': return require('./dns/route53.js'); case 'gcdns': return require('./dns/gcdns.js'); diff --git a/src/dns/desec.js b/src/dns/desec.js new file mode 100644 index 000000000..3932373c2 --- /dev/null +++ b/src/dns/desec.js @@ -0,0 +1,167 @@ +'use strict'; + +exports = module.exports = { + removePrivateFields, + injectPrivateFields, + upsert, + get, + del, + wait, + verifyDomainConfig +}; + +const assert = require('assert'), + constants = require('../constants.js'), + BoxError = require('../boxerror.js'), + debug = require('debug')('box:dns/vultr'), + dig = require('../dig.js'), + dns = require('../dns.js'), + safe = require('safetydance'), + timers = require('timers/promises'), + superagent = require('superagent'), + util = require('util'), + waitForDns = require('./waitfordns.js'); + +const DESEC_ENDPOINT = 'https://desec.io/api/v1'; + +function formatError(response) { + return util.format('deSEC DNS error [%s] %j', response.statusCode, response.body); +} + +function removePrivateFields(domainObject) { + domainObject.config.token = constants.SECRET_PLACEHOLDER; + return domainObject; +} + +function injectPrivateFields(newConfig, currentConfig) { + if (newConfig.token === constants.SECRET_PLACEHOLDER) newConfig.token = currentConfig.token; +} + +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) || '@'; + + await timers.setTimeout(1000); // https://desec.readthedocs.io/en/latest/rate-limits.html + + const [error, response] = await safe(superagent.get(`${DESEC_ENDPOINT}/domains/${zoneName}/rrsets/${name}/${type}`) + .set('Authorization', `Token ${domainConfig.token}`) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 404) return []; + if (response.statusCode === 403 || 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, formatError(response)); + + return response.body.records; +} + +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) || ''; // when upsert, we use '' and not '@' + + await del(domainObject, location, type, values); + + const data = { + subname: name, + type, + ttl: 3600, // min + records: values + }; + + await timers.setTimeout(1000); // https://desec.readthedocs.io/en/latest/rate-limits.html + const [error, response] = await safe(superagent.post(`${DESEC_ENDPOINT}/domains/${zoneName}/rrsets/`) + .set('Authorization', `Token ${domainConfig.token}`) + .send(data) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 403 || response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); +} + +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) || '@'; + + await timers.setTimeout(1000); // https://desec.readthedocs.io/en/latest/rate-limits.html + const [error, response] = await safe(superagent.del(`${DESEC_ENDPOINT}/domains/${zoneName}/rrsets/${name}/${type}/`) + .set('Authorization', `Token ${domainConfig.token}`) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 404) return; + if (response.statusCode === 403 || response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + 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.token || typeof domainConfig.token !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'token must be a non-empty string'); + + const ip = '127.0.0.1'; + + const credentials = { + token: domainConfig.token + }; + + if (constants.TEST) 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('.desec.') !== -1; })) { + debug('verifyDomainConfig: %j does not contains deSEC NS', nameservers); + throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to deSEC'); + } + + 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 a7a1f5ed6..f48b3ad91 100644 --- a/src/domains.js +++ b/src/domains.js @@ -54,6 +54,7 @@ function api(provider) { switch (provider) { case 'bunny': return require('./dns/bunny.js'); case 'cloudflare': return require('./dns/cloudflare.js'); + case 'desec': return require('./dns/desec.js'); case 'dnsimple': return require('./dns/dnsimple.js'); case 'route53': return require('./dns/route53.js'); case 'gcdns': return require('./dns/gcdns.js');