From c4d338ee48d451d09874cdf9db1cc46ff76fb01d Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 16:47:42 +0000 Subject: [PATCH] c9ca35d6-b826-45a9-8f06-c335add2490d --- .env | 20 +++--- src/lib/hetzner-dns.ts | 135 +++++++++++++++++++++++++++-------------- 2 files changed, 102 insertions(+), 53 deletions(-) diff --git a/.env b/.env index a111aa5..2c24d42 100755 --- a/.env +++ b/.env @@ -56,13 +56,19 @@ DOKPLOY_API_KEY=CEIqJlWVtmSNgffNWywTJmEeuvUJBndinBzudretwiuzVWPxuvUNmoQaHUwRwUBy # DOKPLOY_SERVER_IP=123.456.789.012 # ── Hetzner Cloud DNS ── -# API token scoped to the platform zone (dfmpanel.eu) -# HETZNER_DNS_TOKEN=your-hetzner-dns-token -# Zone ID of the platform domain (find via: GET https://dns.hetzner.com/api/v1/zones) -# HETZNER_ZONE_ID=your-zone-id -# If true, a single *.dfmpanel.eu wildcard A record handles all subdomains -# (recommended — avoids per-instance DNS records + Let's Encrypt rate limits) -USE_WILDCARD_DNS=true +# DNS is managed via the Hetzner Cloud API (api.hetzner.cloud/v1/zones). +# The legacy dns.hetzner.com API was shut down in May 2026. +HETZNER_DNS_TOKEN=JhZMOBTMeWvXz91KVPxRh0U4UMFqkSNCMHJKiQS9kUjHIwCkWCc8ViZTm64MyaOp +# Zone ID of dfmpanel.eu (found via: GET https://api.hetzner.cloud/v1/zones) +HETZNER_ZONE_ID=1669672 +# If true, a single *.dfmpanel.eu wildcard A record handles all subdomains. +# If false, a per-instance A record is created/deleted with each instance. +USE_WILDCARD_DNS=false + +# ── Dokploy Server ── +# IP of the Dokploy server (A record target for instance subdomains). +# Found from the existing @ and www A records of dfmpanel.eu. +DOKPLOY_SERVER_IP=92.206.22.59 # ── Cron Job ── # Shared secret for the cleanup cron job (POST /api/aos/instances/cleanup) diff --git a/src/lib/hetzner-dns.ts b/src/lib/hetzner-dns.ts index 4112e32..2acd3a6 100644 --- a/src/lib/hetzner-dns.ts +++ b/src/lib/hetzner-dns.ts @@ -4,15 +4,18 @@ * Manages DNS records for the platform domain (e.g. dfmpanel.eu). * Used to create A records for tenant instance subdomains. * + * NOTE: The legacy Hetzner DNS API (dns.hetzner.com) was shut down in May 2026. + * DNS is now managed via the Hetzner Cloud API (api.hetzner.cloud/v1/zones). + * * Config (env): - * HETZNER_DNS_TOKEN - API token (scoped to the platform zone) - * HETZNER_ZONE_ID - the zone ID of the platform domain (dfmpanel.eu) + * HETZNER_DNS_TOKEN - Hetzner Cloud API token (with DNS scope) + * HETZNER_ZONE_ID - the zone ID of the platform domain (dfmpanel.eu = 1669672) * DOKPLOY_SERVER_IP - the IP of the Dokploy server (A record target) * PLATFORM_DOMAIN - e.g. dfmpanel.eu - * USE_WILDCARD_DNS - if "true", a single *.dfmpanel.eu A record is used + * USE_WILDCARD_DNS - if "true", a single *.platform-domain A record is used * and no per-instance records are needed. * - * API docs: https://dns.hetzner.com/api-docs + * API docs: https://docs.hetzner.cloud/reference/cloud#zones */ export interface HetznerDnsConfig { @@ -24,10 +27,10 @@ export interface HetznerDnsConfig { } export interface DnsRecord { - recordId: string - type: string // A|AAAA|CNAME|TXT - name: string // e.g. k7f3a9 (relative to zone) or @ for apex - value: string + recordId: string // "{name}/{type}" e.g. "k7f3a9/A" + type: string // A|AAAA|CNAME|TXT + name: string // relative to zone: "k7f3a9" or "@" for apex + value: string // first record value (IP for A records) ttl: number zoneId: string } @@ -40,10 +43,10 @@ class HetznerDnsError extends Error { } export function createHetznerDnsClient(config: HetznerDnsConfig) { - const baseUrl = 'https://dns.hetzner.com/api/v1' + const baseUrl = 'https://api.hetzner.cloud/v1' const headers: Record = { 'Content-Type': 'application/json', - 'Auth-API-Token': config.token, + Authorization: `Bearer ${config.token}`, } async function call(path: string, options: { method?: string; body?: unknown } = {}): Promise { @@ -62,7 +65,7 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) { } if (!res.ok) { throw new HetznerDnsError( - `Hetzner DNS ${method} ${path} failed: HTTP ${res.status}`, + `Hetzner Cloud DNS ${method} ${path} failed: HTTP ${res.status}`, res.status, json ) @@ -70,7 +73,9 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) { return json as T } - // --- Records --- + // --- Records (RRsets) --- + // In the Cloud API, DNS records are called "record sets" (rrsets). + // Each rrset has an ID of the form "{name}/{type}" e.g. "k7f3a9/A". async function createARecord(subdomain: string): Promise { if (config.useWildcard) { @@ -84,66 +89,76 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) { zoneId: config.zoneId, } } - const res = await call<{ record: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string } }>( - '/records', + + const res = await call<{ + rrset: { + id: string + name: string + type: string + ttl: number + records: { value: string; comment: string }[] + } + action?: { id: number; status: string; command: string } + }>( + `/zones/${config.zoneId}/rrsets`, { method: 'POST', body: { - zone_id: config.zoneId, type: 'A', name: subdomain, // relative to zone: "k7f3a9" -> k7f3a9.dfmpanel.eu - value: config.dokployServerIp, ttl: 300, + records: [{ value: config.dokployServerIp }], }, } ) return { - recordId: res.record.id, - type: res.record.type, - name: res.record.name, - value: res.record.value, - ttl: res.record.ttl, - zoneId: res.record.zone_id, + recordId: res.rrset.id, // "k7f3a9/A" + type: res.rrset.type, + name: res.rrset.name, + value: res.rrset.records[0]?.value ?? config.dokployServerIp, + ttl: res.rrset.ttl, + zoneId: config.zoneId, } } async function deleteRecord(recordId: string): Promise { if (recordId === 'wildcard' || !recordId) return // wildcard is shared, never delete + // recordId is "{name}/{type}" e.g. "k7f3a9/A" + // Do NOT URL-encode the slash — the Hetzner API expects it as a raw path + // separator. encodeURIComponent would turn "/" into "%2F" and the API + // would return 404 (silently swallowed below), leaving orphaned records. try { - await call(`/records/${recordId}`, { method: 'DELETE' }) + await call(`/zones/${config.zoneId}/rrsets/${recordId}`, { method: 'DELETE' }) } catch (e) { if (e instanceof HetznerDnsError && e.status === 404) return throw e } } - // --- Custom domain verification (TXT records) --- + // --- TXT records (for custom domain verification) --- - async function createTxtRecord(domain: string, value: string): Promise { - // For custom domain verification: _dfm-verify. = token - // The domain may be in a different zone (customer's own), so this is only - // useful if the customer's zone is also managed in this Hetzner account. - // In most cases, the customer manages their own DNS and we only poll. - const res = await call<{ record: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string } }>( - '/records', + async function createTxtRecord(name: string, value: string): Promise { + const res = await call<{ + rrset: { id: string; name: string; type: string; ttl: number; records: { value: string; comment: string }[] } + }>( + `/zones/${config.zoneId}/rrsets`, { method: 'POST', body: { - zone_id: config.zoneId, type: 'TXT', - name: domain, - value, + name, ttl: 300, + records: [{ value }], }, } ) return { - recordId: res.record.id, - type: res.record.type, - name: res.record.name, - value: res.record.value, - ttl: res.record.ttl, - zoneId: res.record.zone_id, + recordId: res.rrset.id, + type: res.rrset.type, + name: res.rrset.name, + value: res.rrset.records[0]?.value ?? value, + ttl: res.rrset.ttl, + zoneId: config.zoneId, } } @@ -203,19 +218,46 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) { // --- List records (for reconciler) --- async function listRecords(): Promise { - const res = await call<{ records: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string }[] }>( - `/records?zone_id=${config.zoneId}` - ) - return (res.records ?? []).map((r) => ({ + const res = await call<{ + rrsets: { + id: string + type: string + name: string + ttl: number + records: { value: string; comment: string }[] + }[] + }>(`/zones/${config.zoneId}/rrsets`) + return (res.rrsets ?? []).map((r) => ({ recordId: r.id, type: r.type, name: r.name, - value: r.value, + value: r.records[0]?.value ?? '', ttl: r.ttl, - zoneId: r.zone_id, + zoneId: config.zoneId, })) } + // --- Zone info --- + + async function getZone(): Promise<{ id: string; name: string; ttl: number; status: string; recordCount: number }> { + const res = await call<{ + zone: { + id: number + name: string + ttl: number + status: string + record_count: number + } + }>(`/zones/${config.zoneId}`) + return { + id: String(res.zone.id), + name: res.zone.name, + ttl: res.zone.ttl, + status: res.zone.status, + recordCount: res.zone.record_count, + } + } + return { createARecord, deleteRecord, @@ -223,6 +265,7 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) { verifyCustomDomainCname, verifyTxtRecord, listRecords, + getZone, } }