Add parked alias domains with Arvan or Cloudflare DNS and websites SSL.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5813e9fc53
commit
d1f60b5313
@@ -0,0 +1,268 @@
|
||||
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[];
|
||||
}> {
|
||||
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[] = [];
|
||||
|
||||
const desired: Array<{ type: 'CNAME'; relative: string; content: string }> = [
|
||||
{ type: 'CNAME', relative: '@', content: TARGET },
|
||||
{ type: 'CNAME', relative: 'www', content: host },
|
||||
];
|
||||
|
||||
for (const item of desired) {
|
||||
const label = `${item.type} ${item.relative}`;
|
||||
const match = existing.find(
|
||||
(rec) => rec.type === item.type && this.relativeName(rec.name, host) === item.relative,
|
||||
);
|
||||
|
||||
if (match && this.sameCname(match, item.content) && match.proxied === false) {
|
||||
skipped.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apex CNAME cannot coexist with A/AAAA.
|
||||
if (item.relative === '@') {
|
||||
await this.deleteConflictingApexAddress(zoneId, token, existing, host);
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const patch = await this.request(
|
||||
'PATCH',
|
||||
`/zones/${zoneId}/dns_records/${match.id}`,
|
||||
token,
|
||||
{
|
||||
type: item.type,
|
||||
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: item.type,
|
||||
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 deleteConflictingApexAddress(
|
||||
zoneId: string,
|
||||
token: string,
|
||||
existing: CfRecord[],
|
||||
zone: string,
|
||||
) {
|
||||
const conflicts = existing.filter(
|
||||
(rec) =>
|
||||
(rec.type === 'A' || rec.type === 'AAAA') && this.relativeName(rec.name, zone) === '@',
|
||||
);
|
||||
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} @ (${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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user