c9ca35d6-b826-45a9-8f06-c335add2490d

This commit is contained in:
Z User committed 2026-10-28 16:47:42 +00:00
1 parent c6adc67479
commit c4d338ee48
2 files changed
+101 -52

No files matched your search

+13 -7
View File
@@ -56,13 +56,19 @@ DOKPLOY_API_KEY=CEIqJlWVtmSNgffNWywTJmEeuvUJBndinBzudretwiuzVWPxuvUNmoQaHUwRwUBy
# DOKPLOY_SERVER_IP=123.456.789.012 # DOKPLOY_SERVER_IP=123.456.789.012
# ── Hetzner Cloud DNS ── # ── Hetzner Cloud DNS ──
# API token scoped to the platform zone (dfmpanel.eu) # DNS is managed via the Hetzner Cloud API (api.hetzner.cloud/v1/zones).
# HETZNER_DNS_TOKEN=your-hetzner-dns-token # The legacy dns.hetzner.com API was shut down in May 2026.
# Zone ID of the platform domain (find via: GET https://dns.hetzner.com/api/v1/zones) HETZNER_DNS_TOKEN=JhZMOBTMeWvXz91KVPxRh0U4UMFqkSNCMHJKiQS9kUjHIwCkWCc8ViZTm64MyaOp
# HETZNER_ZONE_ID=your-zone-id # Zone ID of dfmpanel.eu (found via: GET https://api.hetzner.cloud/v1/zones)
# If true, a single *.dfmpanel.eu wildcard A record handles all subdomains HETZNER_ZONE_ID=1669672
# (recommended — avoids per-instance DNS records + Let's Encrypt rate limits) # If true, a single *.dfmpanel.eu wildcard A record handles all subdomains.
USE_WILDCARD_DNS=true # 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 ── # ── Cron Job ──
# Shared secret for the cleanup cron job (POST /api/aos/instances/cleanup) # Shared secret for the cleanup cron job (POST /api/aos/instances/cleanup)
+88 -45
View File
@@ -4,15 +4,18 @@
* Manages DNS records for the platform domain (e.g. dfmpanel.eu). * Manages DNS records for the platform domain (e.g. dfmpanel.eu).
* Used to create A records for tenant instance subdomains. * 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): * Config (env):
* HETZNER_DNS_TOKEN - API token (scoped to the platform zone) * HETZNER_DNS_TOKEN - Hetzner Cloud API token (with DNS scope)
* HETZNER_ZONE_ID - the zone ID of the platform domain (dfmpanel.eu) * 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) * DOKPLOY_SERVER_IP - the IP of the Dokploy server (A record target)
* PLATFORM_DOMAIN - e.g. dfmpanel.eu * 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. * 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 { export interface HetznerDnsConfig {
@@ -24,10 +27,10 @@ export interface HetznerDnsConfig {
} }
export interface DnsRecord { export interface DnsRecord {
recordId: string recordId: string // "{name}/{type}" e.g. "k7f3a9/A"
type: string // A|AAAA|CNAME|TXT type: string // A|AAAA|CNAME|TXT
name: string // e.g. k7f3a9 (relative to zone) or @ for apex name: string // relative to zone: "k7f3a9" or "@" for apex
value: string value: string // first record value (IP for A records)
ttl: number ttl: number
zoneId: string zoneId: string
} }
@@ -40,10 +43,10 @@ class HetznerDnsError extends Error {
} }
export function createHetznerDnsClient(config: HetznerDnsConfig) { export function createHetznerDnsClient(config: HetznerDnsConfig) {
const baseUrl = 'https://dns.hetzner.com/api/v1' const baseUrl = 'https://api.hetzner.cloud/v1'
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Auth-API-Token': config.token, Authorization: `Bearer ${config.token}`,
} }
async function call<T = unknown>(path: string, options: { method?: string; body?: unknown } = {}): Promise<T> { async function call<T = unknown>(path: string, options: { method?: string; body?: unknown } = {}): Promise<T> {
@@ -62,7 +65,7 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) {
} }
if (!res.ok) { if (!res.ok) {
throw new HetznerDnsError( throw new HetznerDnsError(
`Hetzner DNS ${method} ${path} failed: HTTP ${res.status}`, `Hetzner Cloud DNS ${method} ${path} failed: HTTP ${res.status}`,
res.status, res.status,
json json
) )
@@ -70,7 +73,9 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) {
return json as T 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<DnsRecord> { async function createARecord(subdomain: string): Promise<DnsRecord> {
if (config.useWildcard) { if (config.useWildcard) {
@@ -84,66 +89,76 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) {
zoneId: config.zoneId, 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', method: 'POST',
body: { body: {
zone_id: config.zoneId,
type: 'A', type: 'A',
name: subdomain, // relative to zone: "k7f3a9" -> k7f3a9.dfmpanel.eu name: subdomain, // relative to zone: "k7f3a9" -> k7f3a9.dfmpanel.eu
value: config.dokployServerIp,
ttl: 300, ttl: 300,
records: [{ value: config.dokployServerIp }],
}, },
} }
) )
return { return {
recordId: res.record.id, recordId: res.rrset.id, // "k7f3a9/A"
type: res.record.type, type: res.rrset.type,
name: res.record.name, name: res.rrset.name,
value: res.record.value, value: res.rrset.records[0]?.value ?? config.dokployServerIp,
ttl: res.record.ttl, ttl: res.rrset.ttl,
zoneId: res.record.zone_id, zoneId: config.zoneId,
} }
} }
async function deleteRecord(recordId: string): Promise<void> { async function deleteRecord(recordId: string): Promise<void> {
if (recordId === 'wildcard' || !recordId) return // wildcard is shared, never delete 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 { try {
await call(`/records/${recordId}`, { method: 'DELETE' }) await call(`/zones/${config.zoneId}/rrsets/${recordId}`, { method: 'DELETE' })
} catch (e) { } catch (e) {
if (e instanceof HetznerDnsError && e.status === 404) return if (e instanceof HetznerDnsError && e.status === 404) return
throw e throw e
} }
} }
// --- Custom domain verification (TXT records) --- // --- TXT records (for custom domain verification) ---
async function createTxtRecord(domain: string, value: string): Promise<DnsRecord> { async function createTxtRecord(name: string, value: string): Promise<DnsRecord> {
// For custom domain verification: _dfm-verify.<domain> = token const res = await call<{
// The domain may be in a different zone (customer's own), so this is only rrset: { id: string; name: string; type: string; ttl: number; records: { value: string; comment: string }[] }
// 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. `/zones/${config.zoneId}/rrsets`,
const res = await call<{ record: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string } }>(
'/records',
{ {
method: 'POST', method: 'POST',
body: { body: {
zone_id: config.zoneId,
type: 'TXT', type: 'TXT',
name: domain, name,
value,
ttl: 300, ttl: 300,
records: [{ value }],
}, },
} }
) )
return { return {
recordId: res.record.id, recordId: res.rrset.id,
type: res.record.type, type: res.rrset.type,
name: res.record.name, name: res.rrset.name,
value: res.record.value, value: res.rrset.records[0]?.value ?? value,
ttl: res.record.ttl, ttl: res.rrset.ttl,
zoneId: res.record.zone_id, zoneId: config.zoneId,
} }
} }
@@ -203,19 +218,46 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) {
// --- List records (for reconciler) --- // --- List records (for reconciler) ---
async function listRecords(): Promise<DnsRecord[]> { async function listRecords(): Promise<DnsRecord[]> {
const res = await call<{ records: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string }[] }>( const res = await call<{
`/records?zone_id=${config.zoneId}` rrsets: {
) id: string
return (res.records ?? []).map((r) => ({ type: string
name: string
ttl: number
records: { value: string; comment: string }[]
}[]
}>(`/zones/${config.zoneId}/rrsets`)
return (res.rrsets ?? []).map((r) => ({
recordId: r.id, recordId: r.id,
type: r.type, type: r.type,
name: r.name, name: r.name,
value: r.value, value: r.records[0]?.value ?? '',
ttl: r.ttl, 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 { return {
createARecord, createARecord,
deleteRecord, deleteRecord,
@@ -223,6 +265,7 @@ export function createHetznerDnsClient(config: HetznerDnsConfig) {
verifyCustomDomainCname, verifyCustomDomainCname,
verifyTxtRecord, verifyTxtRecord,
listRecords, listRecords,
getZone,
} }
} }