diff --git a/dashboard/public/translation/en.json b/dashboard/public/translation/en.json index b848fbc24..13faa9b3d 100644 --- a/dashboard/public/translation/en.json +++ b/dashboard/public/translation/en.json @@ -1089,7 +1089,9 @@ "deSecToken": "deSEC Token", "gandiTokenType": "Token Type", "gandiTokenTypeApiKey": "API Key (Deprecated)", - "gandiTokenTypePAT": "Personal Access Token (PAT)" + "gandiTokenTypePAT": "Personal Access Token (PAT)", + "inwxUsername": "Username", + "inwxPassword": "Password" }, "removeDialog": { "title": "Really remove {{ domain }}?", diff --git a/dashboard/public/translation/nl.json b/dashboard/public/translation/nl.json index f95774937..b0d5c6272 100644 --- a/dashboard/public/translation/nl.json +++ b/dashboard/public/translation/nl.json @@ -1503,7 +1503,7 @@ "filemanager": { "title": "Bestandsbeheer", "removeDialog": { - "reallyDelete": "Weet je zeker dat je het volgende wilt verwijderen?" + "reallyDelete": "Wil je het echt verwijderen?" }, "newDirectoryDialog": { "title": "Nieuwe map", diff --git a/dashboard/public/views/domains.html b/dashboard/public/views/domains.html index f7cbe2cb2..ece0f69ce 100644 --- a/dashboard/public/views/domains.html +++ b/dashboard/public/views/domains.html @@ -218,6 +218,17 @@

+ + +
+ + +
+
+ + +
+

diff --git a/dashboard/public/views/domains.js b/dashboard/public/views/domains.js index 3f88d24e0..1914d170c 100644 --- a/dashboard/public/views/domains.js +++ b/dashboard/public/views/domains.js @@ -53,6 +53,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat { name: 'GoDaddy', value: 'godaddy' }, { name: 'Google Cloud DNS', value: 'gcdns' }, { name: 'Hetzner', value: 'hetzner' }, + { name: 'INWX', value: 'inwx' }, { name: 'Linode', value: 'linode' }, { name: 'Name.com', value: 'namecom' }, { name: 'Namecheap', value: 'namecheap' }, @@ -75,6 +76,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat case 'dnsimple': return 'dnsimple'; case 'gandi': return 'Gandi LiveDNS'; case 'hetzner': return 'Hetzner DNS'; + case 'inwx': return 'INWX'; case 'linode': return 'Linode'; case 'namecom': return 'Name.com'; case 'namecheap': return 'Namecheap'; @@ -275,6 +277,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat ovhAppSecret: '', porkbunSecretapikey: '', porkbunApikey: '', + inwxUsername: '', + inwxPassword: '', provider: 'route53', zoneName: '', @@ -343,6 +347,9 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.domainConfigure.namecheapApiKey = domain.provider === 'namecheap' ? domain.config.token : ''; $scope.domainConfigure.namecheapUsername = domain.provider === 'namecheap' ? domain.config.username : ''; + $scope.domainConfigure.inwxUsername = domain.provider === 'inwx' ? domain.config.username : ''; + $scope.domainConfigure.inwxPassword = domain.provider === 'inwx' ? domain.config.password : ''; + $scope.domainConfigure.netcupCustomerNumber = domain.provider === 'netcup' ? domain.config.customerNumber : ''; $scope.domainConfigure.netcupApiKey = domain.provider === 'netcup' ? domain.config.apiKey : ''; $scope.domainConfigure.netcupApiPassword = domain.provider === 'netcup' ? domain.config.apiPassword : ''; @@ -428,6 +435,9 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat } else if (provider === 'namecheap') { data.token = $scope.domainConfigure.namecheapApiKey; data.username = $scope.domainConfigure.namecheapUsername; + } else if (provider === 'inwx') { + data.username = $scope.domainConfigure.inwxUsername; + data.password = $scope.domainConfigure.inwxPassword; } else if (provider === 'netcup') { data.customerNumber = $scope.domainConfigure.netcupCustomerNumber; data.apiKey = $scope.domainConfigure.netcupApiKey; @@ -504,6 +514,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.domainConfigure.nameComUsername = ''; $scope.domainConfigure.namecheapApiKey = ''; $scope.domainConfigure.namecheapUsername = ''; + $scope.domainConfigure.inwxUsername = ''; + $scope.domainConfigure.inwxPassword = ''; $scope.domainConfigure.netcupCustomerNumber = ''; $scope.domainConfigure.netcupApiKey = ''; $scope.domainConfigure.netcupApiPassword = ''; diff --git a/dashboard/setup.html b/dashboard/setup.html index 79eed8f4b..fb7ed1439 100644 --- a/dashboard/setup.html +++ b/dashboard/setup.html @@ -222,6 +222,16 @@ + +
+ + +
+
+ + +
+

diff --git a/package-lock.json b/package-lock.json index bc0254c50..8cbab22e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "db-migrate-mysql": "^2.3.2", "debug": "^4.3.5", "dockerode": "^4.0.2", + "domrobot-client": "^3.2.2", "ejs": "^3.1.10", "express": "^4.19.2", "ipaddr.js": "^2.2.0", @@ -420,6 +421,53 @@ "node": ">= 8" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@sindresorhus/is": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", @@ -1763,6 +1811,17 @@ "node": ">=6" } }, + "node_modules/domrobot-client": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domrobot-client/-/domrobot-client-3.2.2.tgz", + "integrity": "sha512-9q9uVOYi/4K0Sa0JcLIf+AX0Xsh/1OTSyaBXuB4l2LEPacoFb/CWMMw76OSLkV5kyDJ+Igb1NRjQpdRsVr0qlg==", + "dependencies": { + "otplib": "^12.0.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dotenv": { "version": "5.0.1", "license": "BSD-2-Clause", @@ -4313,6 +4372,17 @@ "node": ">= 0.8.0" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/ovh": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ovh/-/ovh-2.0.3.tgz", @@ -5343,6 +5413,14 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/tldjs": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/tldjs/-/tldjs-2.3.1.tgz", diff --git a/package.json b/package.json index 2a9c4a4e7..81f3a19ed 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "db-migrate-mysql": "^2.3.2", "debug": "^4.3.5", "dockerode": "^4.0.2", + "domrobot-client": "^3.2.2", "ejs": "^3.1.10", "express": "^4.19.2", "ipaddr.js": "^2.2.0", diff --git a/src/dns.js b/src/dns.js index fddb607e6..f6bdb440e 100644 --- a/src/dns.js +++ b/src/dns.js @@ -53,6 +53,7 @@ function api(provider) { case 'digitalocean': return require('./dns/digitalocean.js'); case 'gandi': return require('./dns/gandi.js'); case 'godaddy': return require('./dns/godaddy.js'); + case 'inwx': return require('./dns/inwx.js'); case 'linode': return require('./dns/linode.js'); case 'vultr': return require('./dns/vultr.js'); case 'namecom': return require('./dns/namecom.js'); diff --git a/src/dns/cloudflare.js b/src/dns/cloudflare.js index 8be523f7c..9a77c395c 100644 --- a/src/dns/cloudflare.js +++ b/src/dns/cloudflare.js @@ -171,6 +171,7 @@ async function upsert(domainObject, location, type, values) { } for (let j = values.length + 1; j < records.length; j++) { + const [error] = await safe(createRequest('DELETE', `${CLOUDFLARE_ENDPOINT}/zones/${zoneId}/dns_records/${records[j].id}`, domainConfig)); if (error) debug(`upsert: error removing record ${records[j].id}: ${error.message}`); } diff --git a/src/dns/digitalocean.js b/src/dns/digitalocean.js index 29fcad546..3f4753457 100644 --- a/src/dns/digitalocean.js +++ b/src/dns/digitalocean.js @@ -84,7 +84,8 @@ async function upsert(domainObject, location, type, values) { const records = await getZoneRecords(domainConfig, zoneName, name, type); // used to track available records to update instead of create - let i = 0, recordIds = []; + let i = 0; + const recordIds = []; for (let value of values) { let priority = null; diff --git a/src/dns/inwx.js b/src/dns/inwx.js new file mode 100644 index 000000000..456d065bd --- /dev/null +++ b/src/dns/inwx.js @@ -0,0 +1,217 @@ +'use strict'; + +exports = module.exports = { + removePrivateFields, + injectPrivateFields, + upsert, + get, + del, + wait, + verifyDomainConfig +}; + +const { ApiClient, Language } = require('domrobot-client'), + assert = require('assert'), + BoxError = require('../boxerror.js'), + constants = require('../constants.js'), + debug = require('debug')('box:dns/inwx'), + dig = require('../dig.js'), + dns = require('../dns.js'), + safe = require('safetydance'), + waitForDns = require('./waitfordns.js'); + +function formatError(response) { + return `INWX Api error error [Code: [${response.code}] Message: ${response.msg}`; +} + +function removePrivateFields(domainObject) { + domainObject.config.password = constants.SECRET_PLACEHOLDER; + return domainObject; +} + +function injectPrivateFields(newConfig, currentConfig) { + if (newConfig.password === constants.SECRET_PLACEHOLDER) newConfig.password = currentConfig.password; +} + +// https://www.inwx.com/en/help/apidoc/f/ch04.html +function translateError(response) { + if (response.code === 2200 || response.code === 2201 || response.code === 2202) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.code === 2003 || response.code === 2004 || response.code === 2005) throw new BoxError(BoxError.BAD_FIELD, formatError(response)); + if (response.code !== 1000) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); +} + +async function login(domainConfig) { + const apiClient = new ApiClient(ApiClient.API_URL_LIVE, Language.EN, false /* debug mode */); + + const sharedSecret = ''; // 2FA + const [error, response] = await safe(apiClient.login(domainConfig.username, domainConfig.password, sharedSecret)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.code !== 1000) throw new BoxError(BoxError. ACCESS_DENIED, `Api login error. Code: ${response.code} Message: ${response.msg}`); + + return apiClient; +} + +async function getDnsRecords(domainConfig, apiClient, zoneName, fqdn, type) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof apiClient, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof fqdn, 'string'); + assert.strictEqual(typeof type, 'string'); + + debug(`getDnsRecords: ${fqdn} in zone ${zoneName} of type ${type}`); + + const [error, response] = await safe(apiClient.callApi('nameserver.info', { domain: zoneName, name: fqdn, type })); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.code !== 1000) throw translateError(response); + + return response.resData.record || []; // 'record' property will be missing if no 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; + + debug(`upsert: ${location} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`); + + const apiClient = await login(domainConfig); + const fqdn = dns.fqdn(location, domainObject.domain); + const records = await getDnsRecords(domainConfig, apiClient, zoneName, fqdn, type); + + let i = 0; // // used to track available records to update instead of create + + for (let value of values) { + let priority = 0; + + if (type === 'MX') { + priority = parseInt(value.split(' ')[0], 10); + value = value.split(' ')[1]; + } + + if (i >= records.length) { // create a new record + const data = { + type, + name: fqdn, + domain: zoneName, + content: value, + prio: priority, + ttl: 300 // 300 to 2764800 + }; + const [error, response] = await safe(apiClient.callApi('nameserver.createRecord', data)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.code !== 1000) throw translateError(response); + } else { // replace existing record + const data = { + id: records[i].id, + type, + name: fqdn, + content: value, + }; + const [error, response] = await safe(apiClient.callApi('nameserver.updateRecord', data)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.code !== 1000) throw translateError(response); + ++i; // increment, as we have consumed the record + } + } + + for (let j = values.length + 1; j < records.length; j++) { + const [error, response] = await safe(apiClient.callApi('nameserver.deleteRecord', { id: records[j].id })); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.code !== 1000) throw translateError(response); + } +} + +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; + + const apiClient = await login(domainConfig); + const fqdn = dns.fqdn(location, domainObject.domain); + const result = await getDnsRecords(domainConfig, apiClient, zoneName, fqdn, type); + const tmp = result.map(function (record) { return record.content; }); + return tmp; +} + +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; + + debug(`del: ${location} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`); + + const apiClient = await login(domainConfig); + const fqdn = dns.fqdn(location, domainObject.domain); + const result = await getDnsRecords(domainConfig, apiClient, zoneName, fqdn, type); + if (result.length === 0) return; + + const tmp = result.filter(function (record) { return values.some(function (value) { return value === record.content; }); }); + debug('del: %j', tmp); + + for (const r of tmp) { + const [error, response] = await safe(apiClient.callApi('nameserver.deleteRecord', { id: r.id })); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.code !== 1000) throw translateError(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 (typeof domainConfig.username !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'username must be a string'); + if (typeof domainConfig.password !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'password must be a string'); + + const credentials = { + username: domainConfig.username, + password: domainConfig.password + }; + + const ip = '127.0.0.1'; + + 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().search(/inwx|xnameserver|domrobot/) !== -1; })) { + debug('verifyDomainConfig: %j does not contain INWX NS', nameservers); + throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to INWX'); + } + + 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 7c4159882..08bee1521 100644 --- a/src/domains.js +++ b/src/domains.js @@ -62,6 +62,7 @@ function api(provider) { case 'gandi': return require('./dns/gandi.js'); case 'godaddy': return require('./dns/godaddy.js'); case 'hetzner': return require('./dns/hetzner.js'); + case 'inwx': return require('./dns/inwx.js'); case 'linode': return require('./dns/linode.js'); case 'vultr': return require('./dns/vultr.js'); case 'namecom': return require('./dns/namecom.js');