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
+102 -53

No files matched your search

+13 -7
View File
@@ -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)
+89 -46
View File
@@ -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<string, string> = {
'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> {
@@ -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<DnsRecord> {
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<void> {
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<DnsRecord> {
// For custom domain verification: _dfm-verify.<domain> = 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<DnsRecord> {
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<DnsRecord[]> {
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,
}
}