272 lines
8.8 KiB
TypeScript
272 lines
8.8 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'Auth-API-Token': config.token,
|
|
}
|
|
|
|
async function call<T = unknown>(path: string, options: { method?: string; body?: unknown } = {}): Promise<T> {
|
|
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<DnsRecord> {
|
|
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<void> {
|
|
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<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',
|
|
{
|
|
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<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) => ({
|
|
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<typeof createHetznerDnsClient>
|
|
|
|
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())
|
|
}
|