/** * Hetzner Cloud DNS API Client * ------------------------------ * Manages DNS records for the platform domain (e.g. dfmpanel.eu). * Used to create A records for tenant instance subdomains. * * Config (env): * HETZNER_DNS_TOKEN - API token (scoped to the platform zone) * HETZNER_ZONE_ID - the zone ID of the platform domain (dfmpanel.eu) * 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 * and no per-instance records are needed. * * API docs: https://dns.hetzner.com/api-docs */ export interface HetznerDnsConfig { token: string zoneId: string platformDomain: string // e.g. dfmpanel.eu dokployServerIp: string useWildcard?: boolean // if true, don't create per-instance records } export interface DnsRecord { recordId: string type: string // A|AAAA|CNAME|TXT name: string // e.g. k7f3a9 (relative to zone) or @ for apex value: string ttl: number zoneId: string } class HetznerDnsError extends Error { constructor(message: string, public status: number, public body?: unknown) { super(message) this.name = 'HetznerDnsError' } } export function createHetznerDnsClient(config: HetznerDnsConfig) { const baseUrl = 'https://dns.hetzner.com/api/v1' const headers: Record = { 'Content-Type': 'application/json', 'Auth-API-Token': config.token, } async function call(path: string, options: { method?: string; body?: unknown } = {}): Promise { const method = options.method ?? 'GET' const res = await fetch(`${baseUrl}${path}`, { method, headers, body: options.body ? JSON.stringify(options.body) : undefined, }) const text = await res.text() let json: unknown try { json = text ? JSON.parse(text) : null } catch { json = text } if (!res.ok) { throw new HetznerDnsError( `Hetzner DNS ${method} ${path} failed: HTTP ${res.status}`, res.status, json ) } return json as T } // --- Records --- async function createARecord(subdomain: string): Promise { if (config.useWildcard) { // Wildcard record handles all subdomains; no per-instance record needed. return { recordId: 'wildcard', type: 'A', name: subdomain, value: config.dokployServerIp, ttl: 300, zoneId: config.zoneId, } } const res = await call<{ record: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string } }>( '/records', { method: 'POST', body: { zone_id: config.zoneId, type: 'A', name: subdomain, // relative to zone: "k7f3a9" -> k7f3a9.dfmpanel.eu value: config.dokployServerIp, ttl: 300, }, } ) 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, } } async function deleteRecord(recordId: string): Promise { if (recordId === 'wildcard' || !recordId) return // wildcard is shared, never delete try { await call(`/records/${recordId}`, { method: 'DELETE' }) } catch (e) { if (e instanceof HetznerDnsError && e.status === 404) return throw e } } // --- Custom domain verification (TXT records) --- 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', { method: 'POST', body: { zone_id: config.zoneId, type: 'TXT', name: domain, value, ttl: 300, }, } ) 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, } } // --- Verify a custom domain's CNAME points to our platform --- async function verifyCustomDomainCname(customDomain: string, expectedTarget: string): Promise<{ verified: boolean found?: string error?: string }> { try { // Use DNS-over-HTTPS (Google) to resolve the CNAME — we don't manage the // customer's zone, so we can't query Hetzner API for it. const res = await fetch( `https://dns.google/resolve?name=${encodeURIComponent(customDomain)}&type=CNAME`, { headers: { Accept: 'application/dns-json' } } ) const data = await res.json() as { Answer?: { name: string; type: number; data: string }[] } const cnameRecord = data.Answer?.find((a) => a.type === 5) // type 5 = CNAME if (!cnameRecord) { return { verified: false, error: 'No CNAME record found' } } const cnameTarget = cnameRecord.data.replace(/\.$/, '') // strip trailing dot if (cnameTarget === expectedTarget || cnameTarget.endsWith(`.${expectedTarget}`)) { return { verified: true, found: cnameTarget } } return { verified: false, found: cnameTarget, error: `CNAME points to ${cnameTarget}, expected ${expectedTarget}` } } catch (e) { return { verified: false, error: e instanceof Error ? e.message : 'DNS lookup failed' } } } async function verifyTxtRecord(domain: string, expectedToken: string): Promise<{ verified: boolean found?: string error?: string }> { try { const res = await fetch( `https://dns.google/resolve?name=${encodeURIComponent(domain)}&type=TXT`, { headers: { Accept: 'application/dns-json' } } ) const data = await res.json() as { Answer?: { name: string; type: number; data: string }[] } const txtRecords = data.Answer?.filter((a) => a.type === 16) ?? [] // type 16 = TXT for (const r of txtRecords) { const value = r.data.replace(/^"|"$/g, '') // strip quotes if (value === expectedToken) { return { verified: true, found: value } } } return { verified: false, error: 'TXT token not found' } } catch (e) { return { verified: false, error: e instanceof Error ? e.message : 'DNS lookup failed' } } } // --- 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) => ({ recordId: r.id, type: r.type, name: r.name, value: r.value, ttl: r.ttl, zoneId: r.zone_id, })) } return { createARecord, deleteRecord, createTxtRecord, verifyCustomDomainCname, verifyTxtRecord, listRecords, } } export type HetznerDnsClient = ReturnType export function createHetznerDnsClientFromEnv(): HetznerDnsClient | null { const token = process.env.HETZNER_DNS_TOKEN const zoneId = process.env.HETZNER_ZONE_ID const platformDomain = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu' const dokployServerIp = process.env.DOKPLOY_SERVER_IP if (!token || !zoneId || !dokployServerIp) return null return createHetznerDnsClient({ token, zoneId, platformDomain, dokployServerIp, useWildcard: process.env.USE_WILDCARD_DNS === 'true', }) } // --- Subdomain generation (random, collision-checked, base32) --- const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567' // no confusable 0/O, 1/I export function generateSubdomain(length = 7): string { const chars: string[] = [] for (let i = 0; i < length; i++) { chars.push(BASE32_ALPHABET[Math.floor(Math.random() * BASE32_ALPHABET.length)]) } return chars.join('') } // Reserved subdomains that can't be used for instances const RESERVED_SUBDOMAINS = new Set([ 'www', 'mail', 'smtp', 'imap', 'api', 'admin', 'app', 'dashboard', 'docs', 'blog', 'shop', 'portal', 'cdn', 'static', 'assets', 'auth', 'login', 'register', 'signup', 'status', 'help', 'support', 'demo', 'test', 'staging', 'dev', 'prod', 'production', 'ns1', 'ns2', 'dns', 'mx', 'txt', 'cname', 'a', 'aaaa', 'ftp', 'sftp', 'ssh', 'git', 'svn', 'hg', 'autodiscover', 'autoconfig', 'well-known', ]) export function isReservedSubdomain(subdomain: string): boolean { return RESERVED_SUBDOMAINS.has(subdomain.toLowerCase()) }