diff --git a/CHANGES b/CHANGES index ed18794de..8588c11a9 100644 --- a/CHANGES +++ b/CHANGES @@ -2600,4 +2600,5 @@ * services: give static IPs to internal databases * eventlog: only prune login and logout events * Support HSTS preloading +* Add porkbun dns provider diff --git a/dashboard/src/js/setupdns.js b/dashboard/src/js/setupdns.js index 3649e8898..1857989b2 100644 --- a/dashboard/src/js/setupdns.js +++ b/dashboard/src/js/setupdns.js @@ -89,6 +89,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f { name: 'Name.com', value: 'namecom' }, { name: 'Namecheap', value: 'namecheap' }, { name: 'Netcup', value: 'netcup' }, + { name: 'Porkbun', value: 'porkbun' }, { name: 'Vultr', value: 'vultr' }, { name: 'Wildcard', value: 'wildcard' }, { name: 'Manual (not recommended)', value: 'manual' }, @@ -118,6 +119,9 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f netcupCustomerNumber: '', netcupApiKey: '', netcupApiPassword: '', + porkbunSecretapikey: '', + porkbunApikey: '', + provider: 'route53', zoneName: '', tlsConfig: { @@ -210,6 +214,9 @@ 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 === 'porkbun') { + config.apikey = $scope.dnsCredentials.porkbunApikey; + config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey; } var tlsConfig = { diff --git a/dashboard/src/setupdns.html b/dashboard/src/setupdns.html index 3bfc42bfc..8af6b56e5 100644 --- a/dashboard/src/setupdns.html +++ b/dashboard/src/setupdns.html @@ -219,6 +219,16 @@

+ +
+ + +
+
+ + +
+

diff --git a/dashboard/src/translation/en.json b/dashboard/src/translation/en.json index 8c0cca562..badc77067 100644 --- a/dashboard/src/translation/en.json +++ b/dashboard/src/translation/en.json @@ -1037,7 +1037,9 @@ "wellKnownDescription": "The values will be used by Cloudron to respond to /.well-known/ URLs. Note that an app must be available on the bare domain {{ domain }} for this to work. See the docs for more information.", "jitsiHostname": "Jitsi Location", "hetznerToken": "Hetzner Token", - "cloudflareDefaultProxyStatus": "Enable proxying for new DNS records" + "cloudflareDefaultProxyStatus": "Enable proxying for new DNS records", + "porkbunApikey": "API Key", + "porkbunSecretapikey": "Secret API Key" }, "removeDialog": { "title": "Really remove {{ domain }}?", diff --git a/dashboard/src/translation/ru.json b/dashboard/src/translation/ru.json index 269694656..cf2c04671 100644 --- a/dashboard/src/translation/ru.json +++ b/dashboard/src/translation/ru.json @@ -742,7 +742,8 @@ "title": "Robots.txt", "disableIndexingAction": "Отключить индексирование", "txtPlaceholder": "Оставьте пустым, чтобы позволить поисковым ботам индексировать приложение" - } + }, + "hstsPreload": "Активировать предзагрузку HSTS для этого сайта и всех поддоменов" }, "repair": { "recovery": { diff --git a/dashboard/src/views/domains.html b/dashboard/src/views/domains.html index 0f15a4096..2515d1e91 100644 --- a/dashboard/src/views/domains.html +++ b/dashboard/src/views/domains.html @@ -98,15 +98,17 @@ - -

- - + +
+ + +
+
+ +
+
@@ -124,6 +126,14 @@
+
+ + +
+
diff --git a/dashboard/src/views/domains.js b/dashboard/src/views/domains.js index 6a7367aa4..e3645fbda 100644 --- a/dashboard/src/views/domains.js +++ b/dashboard/src/views/domains.js @@ -54,6 +54,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat { name: 'Name.com', value: 'namecom' }, { name: 'Namecheap', value: 'namecheap' }, { name: 'Netcup', value: 'netcup' }, + { name: 'Porkbun', value: 'porkbun' }, { name: 'Vultr', value: 'vultr' }, { name: 'Wildcard', value: 'wildcard' }, { name: 'Manual (not recommended)', value: 'manual' }, @@ -75,6 +76,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat case 'godaddy': return 'GoDaddy'; case 'vultr': return 'Vultr'; case 'manual': return 'Manual'; + case 'porkbun': return 'Porkbun'; case 'wildcard': return 'Wildcard'; case 'noop': return 'No-op'; default: return 'Unknown'; @@ -253,6 +255,9 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat netcupCustomerNumber: '', netcupApiKey: '', netcupApiPassword: '', + porkbunSecretapikey: '', + porkbunApikey: '', + provider: 'route53', zoneName: '', @@ -319,6 +324,9 @@ 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.porkbunApikey = domain.provider === 'porkbun' ? domain.config.porkbunApikey : ''; + $scope.domainConfigure.porkbunSecretapikey = domain.provider === 'porkbun' ? domain.config.porkbunSecretapikey : ''; + $scope.domainConfigure.provider = domain.provider; $scope.domainConfigure.tlsConfig.provider = domain.tlsConfig.provider; @@ -389,6 +397,9 @@ 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 === 'porkbun') { + data.apikey = $scope.domainConfigure.porkbunApikey; + data.secretapikey = $scope.domainConfigure.porkbunSecretapikey; } var fallbackCertificate = null; @@ -455,6 +466,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.domainConfigure.netcupCustomerNumber = ''; $scope.domainConfigure.netcupApiKey = ''; $scope.domainConfigure.netcupApiPassword = ''; + $scope.domainConfigure.porkbunApikey = ''; + $scope.domainConfigure.porkbunSecretapikey = ''; $scope.domainConfigure.vultrToken = ''; $scope.domainConfigure.tlsConfig.provider = 'letsencrypt-prod'; diff --git a/src/dns.js b/src/dns.js index 0b2901b63..a36a546c5 100644 --- a/src/dns.js +++ b/src/dns.js @@ -54,6 +54,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 'porkbun': return require('./dns/porkbun.js'); case 'wildcard': return require('./dns/wildcard.js'); default: return null; } diff --git a/src/dns/porkbun.js b/src/dns/porkbun.js new file mode 100644 index 000000000..473aed355 --- /dev/null +++ b/src/dns/porkbun.js @@ -0,0 +1,195 @@ +'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/porkbun'), + dig = require('../dig.js'), + dns = require('../dns.js'), + safe = require('safetydance'), + superagent = require('superagent'), + waitForDns = require('./waitfordns.js'); + +const PORKBUN_API = 'https://porkbun.com/api/json/v3/dns'; + +function formatError(response) { + return `Porkbun DNS error ${response.statusCode} ${JSON.stringify(response.body)}`; +} + +function removePrivateFields(domainObject) { + domainObject.config.secretapikey = constants.SECRET_PLACEHOLDER; + return domainObject; +} + +function injectPrivateFields(newConfig, currentConfig) { + if (newConfig.secretapikey === constants.SECRET_PLACEHOLDER) newConfig.secretapikey = currentConfig.secretapikey; +} + +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 records = await get(domainObject, location, type); + + const data = { + secretapikey: domainConfig.secretapikey, + apikey: domainConfig.apikey, + ttl: 10, // min seems to be 600 anyway + content: values[0] // for mx records, value is already of the ' ' format + }; + + let url; + if (records.length) { + url = `${PORKBUN_API}/editByNameType/${zoneName}/${type}/${name}`; + } else { // create + url = `${PORKBUN_API}/create/${zoneName}`; + data.type = type; + data.name = name; + } + + if (type === 'MX') { + data.prio = values[0].split(' ')[0]; // string + data.content = values[0].split(' ')[1]; + } else if (type === 'TXT') { + data.content = values[0].startsWith('"') && values[0].endsWith('"') ? values[0].slice(1, values[0].length-1) : values[0]; + } else { + data.content = values[0]; + } + + const [error, response] = await safe(superagent.post(url) + .timeout(30 * 1000) + .send(data) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + if (response.body.status !== 'SUCCESS') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid status in response: ${JSON.stringify(response.body)}`); + if (records.length === 0 && !response.body.id) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid id in response: ${JSON.stringify(response.body)}`); // only for create + + debug(`upsert: created record with id ${response.body.id}`); +} + +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) || ''; + + debug(`get: ${name} in zone ${zoneName} of type ${type}`); + + const data = { + secretapikey: domainConfig.secretapikey, + apikey: domainConfig.apikey, + }; + + const [error, response] = await safe(superagent.post(`${PORKBUN_API}/retrieveByNameType/${zoneName}/${type}/${name}`) + .send(data) + .timeout(30 * 1000) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + if (response.body.status !== 'SUCCESS') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid status in response: ${JSON.stringify(response.body)}`); + if (!Array.isArray(response.body.records)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid records in response: ${JSON.stringify(response.body)}`); + + return response.body.records.map(r => r.content); +} + +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 data = { + secretapikey: domainConfig.secretapikey, + apikey: domainConfig.apikey, + }; + + const [error, response] = await safe(superagent.post(`${PORKBUN_API}/deleteByNameType/${zoneName}/${type}/${name}`) + .send(data) + .timeout(30 * 1000) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 400) return; // not found! + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + if (response.body.status !== 'SUCCESS') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid status in response: ${JSON.stringify(response.body)}`); +} + +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.secretapikey || typeof domainConfig.secretapikey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'secretapikey must be a non-empty string'); + if (!domainConfig.apikey || typeof domainConfig.apikey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'apikey must be a non-empty string'); + + const ip = '127.0.0.1'; + + const credentials = { + secretapikey: domainConfig.secretapikey, + apikey: domainConfig.apikey + }; + + 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('.ns.porkbun.com') !== -1; })) { + debug('verifyDomainConfig: %j does not contain Porkbun NS', nameservers); + throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Porkbun'); + } + + 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 eabc2f39f..ba063ce01 100644 --- a/src/domains.js +++ b/src/domains.js @@ -65,6 +65,7 @@ function api(provider) { case 'netcup': return require('./dns/netcup.js'); case 'noop': return require('./dns/noop.js'); case 'manual': return require('./dns/manual.js'); + case 'porkbun': return require('./dns/porkbun.js'); case 'wildcard': return require('./dns/wildcard.js'); default: return null; } diff --git a/src/mail.js b/src/mail.js index 7848cb0e0..b40f3ab59 100644 --- a/src/mail.js +++ b/src/mail.js @@ -940,13 +940,13 @@ async function upsertDnsRecords(domain, mailFqdn) { const dmarcRecords = await dns.getDnsRecords('_dmarc', domain, 'TXT'); // only update dmarc if absent. this allows user to set email for reporting if (dmarcRecords.length === 0) records.push({ subdomain: '_dmarc', domain: domain, type: 'TXT', values: [ '"v=DMARC1; p=reject; pct=100"' ] }); - debug('upsertDnsRecords: will update %j', records); + debug(`upsertDnsRecords: will update ${domain} with ${JSON.stringify(records)}`); for (const record of records) { await dns.upsertDnsRecords(record.subdomain, record.domain, record.type, record.values); } - debug('upsertDnsRecords: records %j added', records); + debug(`upsertDnsRecords: records of ${domain} added`); } async function setDnsRecords(domain) {