c9ca35d6-b826-45a9-8f06-c335add2490d
This commit is contained in:
1 parent
c6adc67479
commit
c4d338ee48
2 files changed
+102
-53
No files matched your search
@@ -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)
|
||||||
|
|||||||
+89
-46
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user