diff --git a/CHANGES b/CHANGES index c42cccf77..cbf42b716 100644 --- a/CHANGES +++ b/CHANGES @@ -2471,4 +2471,5 @@ * GoDaddy: there is now a delete API * nginx: use ubuntu packages for ubuntu 20.04 and 22.04 * Ubuntu 22.04 LTS support +* Add Hetzner DNS diff --git a/src/dns.js b/src/dns.js index 3f793f05a..d34c3e351 100644 --- a/src/dns.js +++ b/src/dns.js @@ -51,6 +51,7 @@ function api(provider) { case 'namecom': return require('./dns/namecom.js'); case 'namecheap': return require('./dns/namecheap.js'); case 'netcup': return require('./dns/netcup.js'); + case 'hetzner': return require('./dns/hetzner.js'); case 'noop': return require('./dns/noop.js'); case 'manual': return require('./dns/manual.js'); case 'wildcard': return require('./dns/wildcard.js'); diff --git a/src/dns/hetzner.js b/src/dns/hetzner.js new file mode 100644 index 000000000..2420fc8c8 --- /dev/null +++ b/src/dns/hetzner.js @@ -0,0 +1,259 @@ +'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/digitalocean'), + dig = require('../dig.js'), + dns = require('../dns.js'), + safe = require('safetydance'), + superagent = require('superagent'), + waitForDns = require('./waitfordns.js'); + +const ENDPOINT = 'https://dns.hetzner.com/api/v1'; + +function formatError(response) { + return `Hetzner DNS error ${response.statusCode} ${JSON.stringify(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 getZone(domainConfig, zoneName) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + + const [error, response] = await safe(superagent.get(`${ENDPOINT}/zones`) + .set('Auth-API-Token', domainConfig.token) + .query({ search_name: zoneName }) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401 || response.statusCode === 403) 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.zones)) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + const zone = response.body.zones.filter(z => z.name === zoneName); + if (zone.length === 0) throw new BoxError(BoxError.NOT_FOUND, formatError(response)); + return zone[0]; +} + +async function getZoneRecords(domainConfig, zone, name, type) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof zone, 'object'); + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof type, 'string'); + + let page = 1, matchingRecords = []; + + debug(`getInternal: getting dns records of ${zone.name} with ${name} and type ${type}`); + + const perPage = 50; + + // eslint-disable-next-line no-constant-condition + while (true) { + const [error, response] = await safe(superagent.get(`${ENDPOINT}/records`) + .set('Auth-API-Token', domainConfig.token) + .query({ zone_id: zone.id, page, per_page: perPage }) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 404) throw new BoxError(BoxError.NOT_FOUND, formatError(response)); + if (response.statusCode === 401 || response.statusCode === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + matchingRecords = matchingRecords.concat(response.body.records.filter(function (record) { + return (record.type === type && record.name === name); + })); + + if (response.body.records.length < perPage) break; + + ++page; + } + + return matchingRecords; +} + +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: %s for zone %s of type %s with values %j', name, zoneName, type, values); + + const zone = await getZone(domainConfig, zoneName); + const records = await getZoneRecords(domainConfig, zone, name, type); + + // used to track available records to update instead of create + let i = 0; + + for (let value of values) { + const data = { + type, + name, + value, + ttl: 60, + zone_id: zone.id + }; + + if (i >= records.length) { + const [error, response] = await safe(superagent.post(`${ENDPOINT}/records`) + .set('Auth-API-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 === 422) throw new BoxError(BoxError.BAD_FIELD, response.body.message); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + } else { + const [error, response] = await safe(superagent.put(`${ENDPOINT}/records/${records[i].id}`) + .set('Auth-API-Token', domainConfig.token) + .send(data) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + ++i; + + 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 === 422) throw new BoxError(BoxError.BAD_FIELD, response.body.message); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + } + } + + for (let j = values.length + 1; j < records.length; j++) { + const [error] = await safe(superagent.del(`${ENDPOINT}/records/${records[j].id}`) + .set('Auth-API-Token', domainConfig.token) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) debug(`upsert: error removing record ${records[j].id}: ${error.message}`); + } + + 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 zone = await getZone(domainConfig, zoneName); + const result = await getZoneRecords(domainConfig, zone, name, type); + + return result.map(function (record) { return record.value; }); +} + +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) || '@'; + + const zone = await getZone(domainConfig, zoneName); + const records = await getZoneRecords(domainConfig, zone, name, type); + if (records.length === 0) return; + + const matchingRecords = records.filter(function (record) { return values.some(function (value) { return value === record.value; }); }); + if (matchingRecords.length === 0) return; + + for (const r of matchingRecords) { + const [error, response] = await safe(superagent.del(`${ENDPOINT}/records/${r.id}`) + .set('Auth-API-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)); + } +} + +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); + + 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 (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'); + + // https://docs.hetzner.com/dns-console/dns/general/dns-overview#the-hetzner-online-name-servers-are + if (nameservers.map(function (n) { return n.toLowerCase(); }).indexOf('oxygen.ns.hetzner.com') === -1) { + debug('verifyDomainConfig: %j does not contain Hetzner NS', nameservers); + throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Hetzner'); + } + + 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 c8f287e11..16cb4bf33 100644 --- a/src/domains.js +++ b/src/domains.js @@ -54,6 +54,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 'hetzner': return require('./dns/hetzner.js'); case 'linode': return require('./dns/linode.js'); case 'vultr': return require('./dns/vultr.js'); case 'namecom': return require('./dns/namecom.js');