Files
backend/src/cloudflare-dns/cloudflare-dns.service.ts
T
Alireza HassaniandCursor f42e6fe2a4 Allow add/edit domain to upsert tenant DNS on Cloudflare or Arvan.
Update DNS writes @, www, business, customer, and api records on the chosen provider instead of Arvan only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 18:46:48 +03:30

294 lines
8.7 KiB
TypeScript

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<string> {
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<CfRecord[]> {
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<string>('CLOUDFLARE_API_TOKEN')?.trim() || null;
}
private baseUrl(): string {
return (
this.config.get<string>('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<string, unknown>;
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<string, unknown> = {};
try {
parsed = text ? (JSON.parse(text) as Record<string, unknown>) : {};
} 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,
};
}
}