// cloudflare-dns's own code (novox/hq ADR 0044). It provides the mesh `public-dns` interface // (ADR 0049): a public name that resolves to the mesh's public ingress. Cloudflare is one registrar // behind the neutral interface — a consumer names `public-dns`, never Cloudflare — so this file is // the only place Cloudflare's API appears, and swapping registrars swaps only this module. import { readFileSync } from "node:fs"; export interface PublicRecord { id: string; name: string; type: string; content: string; } export class CloudflareClient { constructor( private readonly token: string, private readonly zoneId: string, /** The zone this registers under, e.g. "example.com". */ readonly domain: string, /** What every public name points at — the mesh's public ingress (the reverse proxy). */ readonly ingress: string, ) {} static fromEnv(env: NodeJS.ProcessEnv = process.env): CloudflareClient { // Which zone, domain and ingress are a mesh's own facts, not this module's — so they are // settings, merged into a config file the mesh manages (novox/hq ADR 0051), read here. The // token is the one secret and stays an own-secret. Env is honoured as a fallback for a // hand-run instance, but the deployed path is the config file settings fill. const config = readConfig(env.MESH_CLOUDFLARE_CONFIG_FILE); const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE); const zoneId = config.zone ?? env.MESH_CLOUDFLARE_ZONE_ID; const domain = config.domain ?? env.MESH_PUBLIC_DOMAIN; const ingress = config.ingress ?? env.MESH_PUBLIC_INGRESS; if (!token || !zoneId || !domain || !ingress) { throw new Error( "cloudflare-dns is not configured — set its zone, domain and ingress in settings (and the " + "token as its own-secret); until then it registers nothing", ); } return new CloudflareClient(token, zoneId, domain, ingress); } /** * The public name a consumer gets: derived from its identity under the mesh's domain. Derived, not * contributed, for the same reason minio derives a bucket name — the harness hands `remove` only * the identity, so teardown must recompute exactly what creation made. */ nameFor(consumer: string): string { return `${consumer.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase()}.${this.domain}`; } /** An IP points at itself (A/AAAA); a hostname points through a CNAME. */ private recordType(): "A" | "AAAA" | "CNAME" { if (/^\d{1,3}(\.\d{1,3}){3}$/.test(this.ingress)) return "A"; if (this.ingress.includes(":")) return "AAAA"; return "CNAME"; } private async api(method: string, path: string, body?: unknown): Promise { const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { method, headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = (await res.json()) as { success?: boolean; result?: unknown; errors?: unknown }; if (!res.ok || json.success === false) { throw new Error(`cloudflare ${method} ${path}: ${res.status} ${JSON.stringify(json.errors ?? json)}`); } return json.result as T; } async findRecord(name: string): Promise { const records = await this.api( "GET", `/zones/${this.zoneId}/dns_records?name=${encodeURIComponent(name)}`, ); return records[0]; } /** Point a public name at the mesh's ingress, idempotently — create it, or update one already there. */ async upsert(name: string): Promise { const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false }; const existing = await this.findRecord(name); if (existing) { return this.api("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body); } return this.api("POST", `/zones/${this.zoneId}/dns_records`, body); } /** Remove a public name, idempotently — a record already gone is not an error on reconcile. */ async remove(name: string): Promise { const existing = await this.findRecord(name); if (existing) await this.api("DELETE", `/zones/${this.zoneId}/dns_records/${existing.id}`); } /** Every record in the zone, for the diagnostic tool. */ async records(): Promise { return this.api("GET", `/zones/${this.zoneId}/dns_records`); } } function readSecret(path: string | undefined): string | undefined { if (!path) return undefined; try { return readFileSync(path, "utf8").trim(); } catch { return undefined; } } interface Config { zone?: string; domain?: string; ingress?: string; } /** The settings-managed config file (a JSON document the mesh merges settings into). Absent or * unparseable yields an empty config, which fromEnv then reports as unconfigured. */ function readConfig(path: string | undefined): Config { if (!path) return {}; try { return JSON.parse(readFileSync(path, "utf8")) as Config; } catch { return {}; } }