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');