Update DNS writes @, www, business, customer, and api records on the chosen provider instead of Arvan only. Co-authored-by: Cursor <cursoragent@cursor.com>
294 lines
8.7 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|