import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; const DEFAULT_BASE_URL = 'https://api.cloudflare.com/client/v4'; const TTL = 120; const TARGET = 'ns.meshkee.com'; type CfRecord = { id: string; type: string; name: string; content: string; proxied?: boolean; ttl?: number; }; @Injectable() export class CloudflareDnsService { private readonly logger = new Logger(CloudflareDnsService.name); constructor(private readonly config: ConfigService) {} /** * Parked alias: apex + www CNAME to Meshkee websites, DNS-only (not proxied) * so Let's Encrypt HTTP-01 on the websites VM can complete. */ async ensureParkedRecords(hostRaw: string): Promise<{ created: string[]; updated: string[]; skipped: string[]; }> { return this.ensureCnameRecords(hostRaw, (host) => [ { relative: '@', content: TARGET }, { relative: 'www', content: host }, ]); } /** * Full Meshkee tenant: @ + www + business + customer + api, DNS-only. */ async ensureTenantRecords(hostRaw: string): Promise<{ created: string[]; updated: string[]; skipped: string[]; }> { return this.ensureCnameRecords(hostRaw, (host) => [ { relative: '@', content: TARGET }, { relative: 'www', content: host }, { relative: 'business', content: 'business.meshkee.com' }, { relative: 'customer', content: 'customer.meshkee.com' }, { relative: 'api', content: 'api.meshkee.com' }, ]); } private async ensureCnameRecords( hostRaw: string, desiredFor: (apex: string) => Array<{ relative: string; content: string }>, ): Promise<{ created: string[]; updated: string[]; skipped: string[]; }> { const host = hostRaw.trim().toLowerCase(); if (!host) { throw new ServiceUnavailableException('Domain host is required for Cloudflare DNS'); } const token = this.apiToken(); if (!token) { throw new ServiceUnavailableException( 'Cloudflare DNS is not configured (CLOUDFLARE_API_TOKEN)', ); } const zoneId = await this.findZoneId(host, token); const existing = await this.listRecords(zoneId, token); const created: string[] = []; const updated: string[] = []; const skipped: string[] = []; for (const item of desiredFor(host)) { const label = `CNAME ${item.relative}`; const match = existing.find( (rec) => rec.type === 'CNAME' && this.relativeName(rec.name, host) === item.relative, ); if (match && this.sameCname(match, item.content) && match.proxied === false) { skipped.push(label); continue; } await this.deleteConflictingAddress(zoneId, token, existing, host, item.relative); if (match) { const patch = await this.request( 'PATCH', `/zones/${zoneId}/dns_records/${match.id}`, token, { type: 'CNAME', name: item.relative, content: item.content, ttl: TTL, proxied: false, }, ); if (!patch.ok) { throw new ServiceUnavailableException( `Cloudflare failed to update ${label} (${patch.status})${patch.message ? `: ${patch.message}` : ''}`, ); } updated.push(label); continue; } const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, { type: 'CNAME', name: item.relative, content: item.content, ttl: TTL, proxied: false, }); if (!post.ok) { throw new ServiceUnavailableException( `Cloudflare failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`, ); } created.push(label); } this.logger.log( `Cloudflare DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'}`, ); return { created, updated, skipped }; } private async findZoneId(host: string, token: string): Promise { const res = await this.request( 'GET', `/zones?name=${encodeURIComponent(host)}&status=active`, token, ); if (!res.ok) { throw new ServiceUnavailableException( `Cloudflare could not load zone "${host}" (${res.status})${res.message ? `: ${res.message}` : ''}`, ); } const result = Array.isArray(res.body.result) ? res.body.result : []; const zone = result.find( (item) => item && typeof item === 'object' && 'name' in item && String((item as { name: string }).name).toLowerCase() === host, ) as { id?: string } | undefined; if (!zone?.id) { throw new ServiceUnavailableException( `Cloudflare zone "${host}" was not found. Add the domain in Cloudflare first.`, ); } return zone.id; } private async listRecords(zoneId: string, token: string): Promise { const items: CfRecord[] = []; let page = 1; for (;;) { const res = await this.request( 'GET', `/zones/${zoneId}/dns_records?per_page=100&page=${page}`, token, ); if (!res.ok) { throw new ServiceUnavailableException( `Cloudflare could not list DNS records (${res.status})${res.message ? `: ${res.message}` : ''}`, ); } const data = Array.isArray(res.body.result) ? (res.body.result as CfRecord[]) : []; items.push(...data); const info = res.body.result_info as { total_pages?: number } | undefined; const lastPage = info?.total_pages ?? 1; if (page >= lastPage) break; page += 1; } return items; } private async deleteConflictingAddress( zoneId: string, token: string, existing: CfRecord[], zone: string, relative: string, ) { const conflicts = existing.filter( (rec) => (rec.type === 'A' || rec.type === 'AAAA') && this.relativeName(rec.name, zone) === relative, ); for (const rec of conflicts) { const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token); if (!del.ok) { throw new ServiceUnavailableException( `Cloudflare failed to replace ${rec.type} ${relative} (${del.status})${del.message ? `: ${del.message}` : ''}`, ); } } } private relativeName(name: string, zone: string): string { const n = name.replace(/\.$/, '').toLowerCase(); const z = zone.replace(/\.$/, '').toLowerCase(); if (n === z || n === '@') return '@'; if (n.endsWith(`.${z}`)) return n.slice(0, -(z.length + 1)); return n; } private sameCname(rec: CfRecord, content: string): boolean { return rec.content.replace(/\.$/, '').toLowerCase() === content.replace(/\.$/, '').toLowerCase(); } private apiToken(): string | null { return this.config.get('CLOUDFLARE_API_TOKEN')?.trim() || null; } private baseUrl(): string { return ( this.config.get('CLOUDFLARE_API_BASE_URL')?.trim().replace(/\/$/, '') || DEFAULT_BASE_URL ); } private async request( method: string, path: string, token: string, body?: unknown, ): Promise<{ ok: boolean; status: number; body: Record; message: string; }> { let response: Response; try { response = await fetch(`${this.baseUrl()}${path}`, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(20_000), }); } catch (err) { const detail = err instanceof Error ? err.message : null; this.logger.warn(`Cloudflare DNS request failed ${method} ${path}${detail ? `: ${detail}` : ''}`); throw new ServiceUnavailableException( detail ? `Could not reach Cloudflare DNS API (${detail})` : 'Could not reach Cloudflare DNS API', ); } const text = await response.text().catch(() => ''); let parsed: Record = {}; try { parsed = text ? (JSON.parse(text) as Record) : {}; } catch { /* keep raw */ } const errors = parsed.errors; const errorMessage = Array.isArray(errors) && errors[0] && typeof errors[0] === 'object' && 'message' in errors[0] ? String((errors[0] as { message: unknown }).message) : ''; const message = errorMessage || (typeof parsed.message === 'string' && parsed.message) || (text && !response.ok ? text.slice(0, 300) : ''); return { ok: response.ok && parsed.success !== false, status: response.status, body: parsed, message, }; } }