diff --git a/CHANGES b/CHANGES index cd69cd348..6492b57c1 100644 --- a/CHANGES +++ b/CHANGES @@ -2704,4 +2704,5 @@ * Show disk consumption of docker volumes for /run and /tmp of apps separately * dns: add dnsimple automation * roles: admin role can access branding and networking +* dns: add ovh backend diff --git a/dashboard/src/js/client.js b/dashboard/src/js/client.js index 207f5be6e..2812e08f1 100644 --- a/dashboard/src/js/client.js +++ b/dashboard/src/js/client.js @@ -226,6 +226,16 @@ const REGIONS_OVH = [ { name: 'Warsaw (WAW)', value: 'https://s3.waw.cloud.ovh.net', region: 'waw' }, ]; +const ENDPOINTS_OVH = [ + { name: 'OVH Europe', value: 'ovh-eu' }, + { name: 'OVH US', value: 'ovh-us' }, + { name: 'OVH North-America', value: 'ovh-ca' }, + { name: 'SoYouStart Europe', value: 'soyoustart-eu' }, + { name: 'SoYouStart North-America', value: 'soyoustart-ca' }, + { name: 'Kimsufi Europe', value: 'kimsufi-eu' }, + { name: 'Kimsufi North-America', value: 'kimsufi-ca' }, +]; + // https://docs.ionos.com/cloud/managed-services/s3-object-storage/endpoints const REGIONS_IONOS = [ { name: 'Frankfurt (DE)', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default diff --git a/dashboard/src/js/setupdns.js b/dashboard/src/js/setupdns.js index 2ecf91b8c..c41078cdf 100644 --- a/dashboard/src/js/setupdns.js +++ b/dashboard/src/js/setupdns.js @@ -1,6 +1,6 @@ 'use strict'; -/* global $, tld, angular, Clipboard */ +/* global $, tld, angular, Clipboard, ENDPOINTS_OVH */ // create main application module var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']); @@ -55,6 +55,8 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f } }; + $scope.ovhEndpoints = ENDPOINTS_OVH; + $scope.needsPort80 = function (dnsProvider, tlsProvider) { return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') && (tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging')); @@ -91,6 +93,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f { name: 'Name.com', value: 'namecom' }, { name: 'Namecheap', value: 'namecheap' }, { name: 'Netcup', value: 'netcup' }, + { name: 'OVH', value: 'ovh' }, { name: 'Porkbun', value: 'porkbun' }, { name: 'Vultr', value: 'vultr' }, { name: 'Wildcard', value: 'wildcard' }, @@ -123,6 +126,10 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f netcupCustomerNumber: '', netcupApiKey: '', netcupApiPassword: '', + ovhEndpoint: 'ovh-eu', + ovhConsumerKey: '', + ovhAppKey: '', + ovhAppSecret: '', porkbunSecretapikey: '', porkbunApikey: '', @@ -222,6 +229,11 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber; config.apiKey = $scope.dnsCredentials.netcupApiKey; config.apiPassword = $scope.dnsCredentials.netcupApiPassword; + } else if (provider === 'ovh') { + config.endpoint = $scope.dnsCredentials.ovhEndpoint; + config.consumerKey = $scope.dnsCredentials.ovhConsumerKey; + config.appKey = $scope.dnsCredentials.ovhAppKey; + config.appSecret = $scope.dnsCredentials.ovhAppSecret; } else if (provider === 'porkbun') { config.apikey = $scope.dnsCredentials.porkbunApikey; config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey; diff --git a/dashboard/src/setupdns.html b/dashboard/src/setupdns.html index dfe7c94b4..bed482e8e 100644 --- a/dashboard/src/setupdns.html +++ b/dashboard/src/setupdns.html @@ -232,15 +232,33 @@

+ +

+ + +

+

+ + +

+

+ + +

+

+ + +

+ -
+

-

-
- - -
+

+

+ + +

diff --git a/dashboard/src/translation/en.json b/dashboard/src/translation/en.json index 5ebb637b1..ed456aec2 100644 --- a/dashboard/src/translation/en.json +++ b/dashboard/src/translation/en.json @@ -1056,7 +1056,11 @@ "porkbunApikey": "API Key", "porkbunSecretapikey": "Secret API Key", "bunnyAccessKey": "Bunny Access Key", - "dnsimpleAccessToken": "Access Token" + "dnsimpleAccessToken": "Access Token", + "ovhEndpoint": "Endpoint", + "ovhConsumerKey": "Consumer Key", + "ovhAppKey": "Application Key", + "ovhAppSecret": "Application Secret" }, "removeDialog": { "title": "Really remove {{ domain }}?", diff --git a/dashboard/src/translation/nl.json b/dashboard/src/translation/nl.json index ae462bbef..1d0d4f630 100644 --- a/dashboard/src/translation/nl.json +++ b/dashboard/src/translation/nl.json @@ -824,7 +824,8 @@ "cloudflareDefaultProxyStatus": "Inschakelen proxy voor nieuwe DNS regels", "porkbunApikey": "API sleutel", "porkbunSecretapikey": "Geheime API sleutel", - "bunnyAccessKey": "Bunny toegangssleutel" + "bunnyAccessKey": "Bunny toegangssleutel", + "dnsimpleAccessToken": "Toegangstoken" }, "title": "Domeinen & Certificaten", "addDomain": "Domein toevoegen", diff --git a/dashboard/src/views/domains.html b/dashboard/src/views/domains.html index ccc61128a..04819eb1e 100644 --- a/dashboard/src/views/domains.html +++ b/dashboard/src/views/domains.html @@ -98,6 +98,25 @@ + +

+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
diff --git a/dashboard/src/views/domains.js b/dashboard/src/views/domains.js index 0b661b255..7866b78be 100644 --- a/dashboard/src/views/domains.js +++ b/dashboard/src/views/domains.js @@ -2,7 +2,7 @@ /* global async */ /* global angular */ -/* global $, TASK_TYPES */ +/* global $, TASK_TYPES, ENDPOINTS_OVH */ angular.module('Application').controller('DomainsController', ['$scope', '$location', 'Client', function ($scope, $location, Client) { Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); }); @@ -56,6 +56,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat { name: 'Name.com', value: 'namecom' }, { name: 'Namecheap', value: 'namecheap' }, { name: 'Netcup', value: 'netcup' }, + { name: 'OVH', value: 'ovh' }, { name: 'Porkbun', value: 'porkbun' }, { name: 'Vultr', value: 'vultr' }, { name: 'Wildcard', value: 'wildcard' }, @@ -76,6 +77,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat case 'namecom': return 'Name.com'; case 'namecheap': return 'Namecheap'; case 'netcup': return 'Netcup'; + case 'ovh': return 'OVH'; case 'gcdns': return 'Google Cloud'; case 'godaddy': return 'GoDaddy'; case 'vultr': return 'Vultr'; @@ -87,6 +89,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat } }; + $scope.ovhEndpoints = ENDPOINTS_OVH; + $scope.needsPort80 = function (dnsProvider, tlsProvider) { return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') && (tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging')); @@ -261,6 +265,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat netcupCustomerNumber: '', netcupApiKey: '', netcupApiPassword: '', + ovhEndpoint: 'ovh-eu', + ovhConsumerKey: '', + ovhAppKey: '', + ovhAppSecret: '', porkbunSecretapikey: '', porkbunApikey: '', @@ -332,6 +340,11 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.domainConfigure.netcupApiKey = domain.provider === 'netcup' ? domain.config.apiKey : ''; $scope.domainConfigure.netcupApiPassword = domain.provider === 'netcup' ? domain.config.apiPassword : ''; + $scope.domainConfigure.ovhEndpoint = domain.provider === 'ovh' ? domain.config.endpoint : ''; + $scope.domainConfigure.ovhConsumerKey = domain.provider === 'ovh' ? domain.config.consumerKey : ''; + $scope.domainConfigure.ovhAppKey = domain.provider === 'ovh' ? domain.config.appKey : ''; + $scope.domainConfigure.ovhAppSecret = domain.provider === 'ovh' ? domain.config.appSecret : ''; + $scope.domainConfigure.porkbunApikey = domain.provider === 'porkbun' ? domain.config.apikey : ''; $scope.domainConfigure.porkbunSecretapikey = domain.provider === 'porkbun' ? domain.config.secretapikey : ''; @@ -409,6 +422,11 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat data.customerNumber = $scope.domainConfigure.netcupCustomerNumber; data.apiKey = $scope.domainConfigure.netcupApiKey; data.apiPassword = $scope.domainConfigure.netcupApiPassword; + } else if (provider === 'ovh') { + data.endpoint = $scope.domainConfigure.ovhEndpoint; + data.consumerKey = $scope.domainConfigure.ovhConsumerKey; + data.appKey = $scope.domainConfigure.ovhAppKey; + data.appSecret = $scope.domainConfigure.ovhAppSecret; } else if (provider === 'porkbun') { data.apikey = $scope.domainConfigure.porkbunApikey; data.secretapikey = $scope.domainConfigure.porkbunSecretapikey; @@ -478,6 +496,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.domainConfigure.netcupCustomerNumber = ''; $scope.domainConfigure.netcupApiKey = ''; $scope.domainConfigure.netcupApiPassword = ''; + $scope.domainConfigure.ovhEndpoint = ''; + $scope.domainConfigure.ovhConsumerKey = ''; + $scope.domainConfigure.ovhAppKey = ''; + $scope.domainConfigure.ovhAppSecret = ''; $scope.domainConfigure.porkbunApikey = ''; $scope.domainConfigure.porkbunSecretapikey = ''; $scope.domainConfigure.vultrToken = ''; diff --git a/package-lock.json b/package-lock.json index f3537bfec..1d34bbb7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "nodemailer": "^6.9.4", "nsyslog-parser": "^0.10.1", "oidc-provider": "^8.2.2", + "ovh": "^2.0.3", "qrcode": "^1.5.3", "readdirp": "^3.6.0", "safetydance": "^2.2.0", @@ -4219,6 +4220,20 @@ "node": ">= 0.8.0" } }, + "node_modules/ovh": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ovh/-/ovh-2.0.3.tgz", + "integrity": "sha512-K2XpfSYza7PHVDqAP6BNk92X3f+BvaNd6eSgMqguLTaUz/DYzOyHsbGFu7lyHTkIUUb9qamvJ9FC6OTzk7tj3Q==", + "dependencies": { + "async": "0.9.x", + "bluebird": "^3.4.0" + } + }, + "node_modules/ovh/node_modules/async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==" + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", diff --git a/package.json b/package.json index 48e983777..fbcd0f8d3 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "nodemailer": "^6.9.4", "nsyslog-parser": "^0.10.1", "oidc-provider": "^8.2.2", + "ovh": "^2.0.3", "qrcode": "^1.5.3", "readdirp": "^3.6.0", "safetydance": "^2.2.0", diff --git a/src/dns.js b/src/dns.js index a45040526..569d80469 100644 --- a/src/dns.js +++ b/src/dns.js @@ -60,6 +60,7 @@ function api(provider) { case 'hetzner': return require('./dns/hetzner.js'); case 'noop': return require('./dns/noop.js'); case 'manual': return require('./dns/manual.js'); + case 'ovh': return require('./dns/ovh.js'); case 'porkbun': return require('./dns/porkbun.js'); case 'wildcard': return require('./dns/wildcard.js'); default: return null; diff --git a/src/dns/dnsimple.js b/src/dns/dnsimple.js index 30ac04e5b..59084c272 100644 --- a/src/dns/dnsimple.js +++ b/src/dns/dnsimple.js @@ -209,7 +209,7 @@ async function del(domainObject, location, type, values) { 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 === 404) continue; if (response.statusCode !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); } } diff --git a/src/dns/ovh.js b/src/dns/ovh.js new file mode 100644 index 000000000..a448a7a14 --- /dev/null +++ b/src/dns/ovh.js @@ -0,0 +1,233 @@ +'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/ovh'), + dig = require('../dig.js'), + dns = require('../dns.js'), + ovhClient = require('ovh'), + safe = require('safetydance'), + waitForDns = require('./waitfordns.js'); + +function formatError(error) { + return `OVH DNS error ${error.error} ${error.message}`; // error.error is the statusCode +} + +function removePrivateFields(domainObject) { + domainObject.config.appSecret = constants.SECRET_PLACEHOLDER; + return domainObject; +} + +function injectPrivateFields(newConfig, currentConfig) { + if (newConfig.appSecret === constants.SECRET_PLACEHOLDER) newConfig.appSecret = currentConfig.appSecret; +} + +function createClient(domainConfig) { + return ovhClient({ + endpoint: domainConfig.endpoint, + appKey: domainConfig.appKey, + appSecret: domainConfig.appSecret, + consumerKey: domainConfig.consumerKey, + }); +} + +async function getDnsRecordIds(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 client = createClient(domainConfig); + const [error, data] = await safe(client.requestPromised('GET', `/domain/zone/${zoneName}/record`, { fieldType: type, subDomain: name })); + if (error) { + if (error.error === 401 || error.error === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error)); + throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error)); + } + return data || []; // array of numbers. data is undefined when no entries +} + +async function refreshZone(domainConfig, zoneName) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + + debug(`refresh: zone ${zoneName}`); + + const client = createClient(domainConfig); + const [error] = await safe(client.requestPromised('POST', `/domain/zone/${zoneName}/refresh`)); + if (error) { + if (error.error === 401 || error.error === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error)); + throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error)); + } +} + +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 recordIds = await getDnsRecordIds(domainConfig, zoneName, name, type); + + const client = createClient(domainConfig); + + // used to track available records to update instead of create + let i = 0; + + for (let value of values) { + const data = { + subDomain: name, + target: value, + ttl: 60 + }; + + let error; + if (i >= recordIds.length) { + data.fieldType = type; + [error] = await safe(client.requestPromised('POST', `/domain/zone/${zoneName}/record`, data)); + } else { + [error] = await safe(client.requestPromised('PUT', `/domain/zone/${zoneName}/record/${recordIds[i]}`, data)); + ++i; + } + + if (error) { + if (error.error === 401 || error.error === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error)); + throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error)); + } + } + + for (let j = values.length + 1; j < recordIds.length; j++) { + const [error] = await safe(client.requestPromised('DELETE', `/domain/zone/${zoneName}/record/${recordIds[j]}`)); + if (error) { + if (error.error === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error)); + if (error.error === 404) continue; // not found + throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error)); + } + } + + await refreshZone(domainConfig, zoneName); + debug('upsert: completed'); +} + +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 recordIds = await getDnsRecordIds(domainConfig, zoneName, name, type); + const client = createClient(domainConfig); + const result = []; + for (const id of recordIds) { + const [error, data] = await safe(client.requestPromised('GET', `/domain/zone/${zoneName}/record/${id}`)); + if (error) { + if (error.error === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error)); + throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error)); + } + result.push(data.target); + } + + return result; +} + +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 recordIds = await getDnsRecordIds(domainConfig, zoneName, name, type); + + const client = createClient(domainConfig); + for (const id of recordIds) { + const [error] = await safe(client.requestPromised('DELETE', `/domain/zone/${zoneName}/record/${id}`)); + if (error) { + if (error.error === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(error)); + if (error.error === 404) continue; // not found + throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(error)); + } + } + + await refreshZone(domainConfig, zoneName); +} + +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.endpoint || typeof domainConfig.endpoint !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'endpoint must be a non-empty string'); + if (!domainConfig.appKey || typeof domainConfig.appKey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'appKey must be a non-empty string'); + if (!domainConfig.appSecret || typeof domainConfig.appSecret !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'appSecret must be a non-empty string'); + if (!domainConfig.consumerKey || typeof domainConfig.consumerKey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'consumerKey must be a non-empty string'); + + const ip = '127.0.0.1'; + + const credentials = { + endpoint: domainConfig.endpoint, // https://github.com/ovh/node-ovh#2-authorize-your-application-to-access-to-a-customer-account + appKey: domainConfig.appKey, + appSecret: domainConfig.appSecret, + consumerKey: domainConfig.consumerKey, + }; + + if (constants.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('ovh.net') !== -1; })) { // SoYouStart and Kimsufi can also be accomdated + debug('verifyDomainConfig: %j does not contain OVH NS', nameservers); + throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to OVH'); + } + + 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 a2d6dc2e9..38abfa073 100644 --- a/src/domains.js +++ b/src/domains.js @@ -67,6 +67,7 @@ function api(provider) { case 'namecheap': return require('./dns/namecheap.js'); case 'netcup': return require('./dns/netcup.js'); case 'noop': return require('./dns/noop.js'); + case 'ovh': return require('./dns/ovh.js'); case 'manual': return require('./dns/manual.js'); case 'porkbun': return require('./dns/porkbun.js'); case 'wildcard': return require('./dns/wildcard.js');