From 5ba30d0236be2153e4e89bd8891a7ffb4b7a1ca3 Mon Sep 17 00:00:00 2001 From: Johannes Zellner Date: Fri, 10 Oct 2025 11:18:04 +0200 Subject: [PATCH] add hetznercloud DNS provider --- .../src/components/DomainProviderForm.vue | 2 +- dashboard/src/models/DomainsModel.js | 2 + src/dns.js | 1 + src/dns/hetzner.js | 20 +- src/dns/hetznercloud.js | 238 ++++++++++++++++++ src/domains.js | 1 + 6 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 src/dns/hetznercloud.js diff --git a/dashboard/src/components/DomainProviderForm.vue b/dashboard/src/components/DomainProviderForm.vue index f910ead1c..b162ba5ac 100644 --- a/dashboard/src/components/DomainProviderForm.vue +++ b/dashboard/src/components/DomainProviderForm.vue @@ -259,7 +259,7 @@ function onGcdnsFileInputChange(event) { - + diff --git a/dashboard/src/models/DomainsModel.js b/dashboard/src/models/DomainsModel.js index 14b4ea484..f3c65a70a 100644 --- a/dashboard/src/models/DomainsModel.js +++ b/dashboard/src/models/DomainsModel.js @@ -13,6 +13,7 @@ const providers = [ { name: 'GoDaddy', value: 'godaddy' }, { name: 'Google Cloud DNS', value: 'gcdns' }, { name: 'Hetzner', value: 'hetzner' }, + { name: 'Hetzner Cloud', value: 'hetznercloud' }, { name: 'INWX', value: 'inwx' }, { name: 'Linode', value: 'linode' }, { name: 'Name.com', value: 'namecom' }, @@ -53,6 +54,7 @@ function filterConfigForProvider(provider, config) { props = ['accessToken']; break; case 'hetzner': + case 'hetznercloud': props = ['token']; break; case 'vultr': diff --git a/src/dns.js b/src/dns.js index 4d98d757d..dff824e34 100644 --- a/src/dns.js +++ b/src/dns.js @@ -60,6 +60,7 @@ function api(provider) { case 'namecheap': return require('./dns/namecheap.js'); case 'netcup': return require('./dns/netcup.js'); case 'hetzner': return require('./dns/hetzner.js'); + case 'hetznercloud': return require('./dns/hetznercloud.js'); case 'noop': return require('./dns/noop.js'); case 'manual': return require('./dns/manual.js'); case 'ovh': return require('./dns/ovh.js'); diff --git a/src/dns/hetzner.js b/src/dns/hetzner.js index e3afa2805..4dccd94b1 100644 --- a/src/dns/hetzner.js +++ b/src/dns/hetzner.js @@ -20,7 +20,8 @@ const assert = require('node:assert'), superagent = require('@cloudron/superagent'), waitForDns = require('./waitfordns.js'); -const ENDPOINT = 'https://dns.hetzner.com/api/v1'; +// const ENDPOINT = 'https://dns.hetzner.com/api/v1'; +const ENDPOINT = 'https://api.hetzner.cloud/v1'; function formatError(response) { return `Hetzner DNS error ${response.status} ${response.text}`; @@ -40,7 +41,7 @@ async function getZone(domainConfig, zoneName) { assert.strictEqual(typeof zoneName, 'string'); const [error, response] = await safe(superagent.get(`${ENDPOINT}/zones`) - .set('Auth-API-Token', domainConfig.token) + .set('Authorization', `Bearer ${domainConfig.token}`) .query({ search_name: zoneName }) .timeout(30 * 1000) .retry(5) @@ -70,7 +71,8 @@ async function getZoneRecords(domainConfig, zone, name, type) { while (true) { const [error, response] = await safe(superagent.get(`${ENDPOINT}/records`) - .set('Auth-API-Token', domainConfig.token) + .set('Authorization', `Bearer ${domainConfig.token}`) + // .set('Auth-API-Token', domainConfig.token) .query({ zone_id: zone.id, page, per_page: perPage }) .timeout(30 * 1000) .retry(5) @@ -122,7 +124,8 @@ async function upsert(domainObject, location, type, values) { if (i >= records.length) { const [error, response] = await safe(superagent.post(`${ENDPOINT}/records`) - .set('Auth-API-Token', domainConfig.token) + .set('Authorization', `Bearer ${domainConfig.token}`) + // .set('Auth-API-Token', domainConfig.token) .send(data) .timeout(30 * 1000) .retry(5) @@ -134,7 +137,8 @@ async function upsert(domainObject, location, type, values) { if (response.status !== 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) + .set('Authorization', `Bearer ${domainConfig.token}`) + // .set('Auth-API-Token', domainConfig.token) .send(data) .timeout(30 * 1000) .retry(5) @@ -151,7 +155,8 @@ async function upsert(domainObject, location, type, values) { 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) + .set('Authorization', `Bearer ${domainConfig.token}`) + // .set('Auth-API-Token', domainConfig.token) .timeout(30 * 1000) .retry(5) .ok(() => true)); @@ -196,7 +201,8 @@ async function del(domainObject, location, type, values) { for (const r of matchingRecords) { const [error, response] = await safe(superagent.del(`${ENDPOINT}/records/${r.id}`) - .set('Auth-API-Token', domainConfig.token) + .set('Authorization', `Bearer ${domainConfig.token}`) + // .set('Auth-API-Token', domainConfig.token) .timeout(30 * 1000) .retry(5) .ok(() => true)); diff --git a/src/dns/hetznercloud.js b/src/dns/hetznercloud.js new file mode 100644 index 000000000..d582f2bcc --- /dev/null +++ b/src/dns/hetznercloud.js @@ -0,0 +1,238 @@ +'use strict'; + +exports = module.exports = { + removePrivateFields, + injectPrivateFields, + upsert, + get, + del, + wait, + verifyDomainConfig +}; + +const assert = require('node:assert'), + BoxError = require('../boxerror.js'), + constants = require('../constants.js'), + debug = require('debug')('box:dns/hetznercloud'), + dig = require('../dig.js'), + dns = require('../dns.js'), + promiseRetry = require('../promise-retry.js'), + safe = require('safetydance'), + superagent = require('@cloudron/superagent'), + waitForDns = require('./waitfordns.js'); + +// https://docs.hetzner.cloud/reference/cloud + +const ENDPOINT = 'https://api.hetzner.cloud/v1'; + +function formatError(response) { + return `Hetzner DNS error ${response.status} ${response.text}`; +} + +function removePrivateFields(domainObject) { + delete domainObject.config.token; + return domainObject; +} + +function injectPrivateFields(newConfig, currentConfig) { + if (!Object.hasOwn(newConfig, 'token')) 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('Authorization', `Bearer ${domainConfig.token}`) + .query({ search_name: zoneName }) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.status !== 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.find(z => z.name === zoneName); + if (!zone) throw new BoxError(BoxError.NOT_FOUND, formatError(response)); + return zone; +} + +async function getRecords(domainConfig, zone, name, type) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof zone, 'object'); + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof type, 'string'); + + debug(`getRecords: getting dns records of ${zone.name} with ${name} and type ${type}`); + + const [error, response] = await safe(superagent.get(`${ENDPOINT}/zones/${zone.id}/rrsets/${name}/${type.toUpperCase()}`) + .set('Authorization', `Bearer ${domainConfig.token}`) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.status === 404) return []; + if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + return response.body.rrset.records; +} + +async function waitForAction(domainConfig, id) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof id, 'number'); + + await promiseRetry({ times: 100, interval: 1000, debug }, async () => { + const [error, response] = await safe(superagent.get(`${ENDPOINT}/actions/${id}`) + .set('Authorization', `Bearer ${domainConfig.token}`) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.status === 404) return []; + if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + if (response.body.action.status !== 'success') throw new BoxError(BoxError.TRY_AGAIN, 'action not done yet'); + }); +} + +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} for zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`); + + const zone = await getZone(domainConfig, zoneName); + const records = await getRecords(domainConfig, zone, name, type); + + // update means first delete then recreate + if (records.length) await del(domainObject, location, type, values); + + const data = { + name, + type, + ttl: 60, + records: values.map(v => { return { value: v, comment: 'managed by cloudron' }; }), + labels: { + managedBy: 'cloudron' + } + }; + + const [error, response] = await safe(superagent.post(`${ENDPOINT}/zones/${zone.id}/rrsets`) + .set('Authorization', `Bearer ${domainConfig.token}`) + .send(data) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.status === 422) throw new BoxError(BoxError.BAD_FIELD, response.body.message); + if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + await waitForAction(domainConfig, response.body.action.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) || '@'; + + const zone = await getZone(domainConfig, zoneName); + const result = await getRecords(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 getRecords(domainConfig, zone, name, type); + if (records.length === 0) return; + + const [error, response] = await safe(superagent.del(`${ENDPOINT}/zones/${zone.id}/rrsets/${name}/${type}`) + .set('Authorization', `Bearer ${domainConfig.token}`) + .timeout(30 * 1000) + .retry(5) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); + if (response.status === 404) return; + if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + await waitForAction(domainConfig, response.body.action.id); +} + +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'); + if ('customNameservers' in domainConfig && typeof domainConfig.customNameservers !== 'boolean') throw new BoxError(BoxError.BAD_FIELD, 'customNameservers must be a boolean'); + + const ip = '127.0.0.1'; + + const credentials = { + token: domainConfig.token, + customNameservers: !!domainConfig.customNameservers + }; + + 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'); + + // https://docs.hetzner.com/dns-console/dns/general/dns-overview#the-hetzner-online-name-servers-are + if (!nameservers.every(function (n) { return n.toLowerCase().search(/hetzner|your-server|second-ns/) !== -1; })) { + debug('verifyDomainConfig: %j does not contain Hetzner NS', nameservers); + if (!domainConfig.customNameservers) 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 8ae403292..0e1116d29 100644 --- a/src/domains.js +++ b/src/domains.js @@ -65,6 +65,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 'hetznercloud': return require('./dns/hetznercloud.js'); case 'inwx': return require('./dns/inwx.js'); case 'linode': return require('./dns/linode.js'); case 'vultr': return require('./dns/vultr.js');