The first registrar behind the neutral public-dns interface. Provider shape
like minio: provides public-dns, a provisioner that registers a consumer's
public name at Cloudflare pointing it at the mesh's ingress, and removes it on
withdrawal. The name is derived from the consumer identity under the mesh's
domain (so stateless teardown recomputes it); the returned {fqdn,target,ttl}
is public, the Cloudflare token the only secret and it never leaves. Emits
record.created/.removed (best-effort). A cloudflare_dns_records diagnostic tool.
Config (zone, domain, ingress) is left to settings, so it fails closed until a
mesh provides them. Typechecks; manifest parses.
106 lines
4.2 KiB
TypeScript
106 lines
4.2 KiB
TypeScript
// 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 {
|
|
const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE);
|
|
const zoneId = env.MESH_CLOUDFLARE_ZONE_ID;
|
|
const domain = env.MESH_PUBLIC_DOMAIN;
|
|
const ingress = env.MESH_PUBLIC_INGRESS;
|
|
if (!token || !zoneId || !domain || !ingress) {
|
|
throw new Error(
|
|
"cloudflare-dns needs MESH_CLOUDFLARE_TOKEN (or _FILE), MESH_CLOUDFLARE_ZONE_ID, " +
|
|
"MESH_PUBLIC_DOMAIN and MESH_PUBLIC_INGRESS — it cannot register a name without them",
|
|
);
|
|
}
|
|
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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
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<PublicRecord | undefined> {
|
|
const records = await this.api<PublicRecord[]>(
|
|
"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<PublicRecord> {
|
|
const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false };
|
|
const existing = await this.findRecord(name);
|
|
if (existing) {
|
|
return this.api<PublicRecord>("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body);
|
|
}
|
|
return this.api<PublicRecord>("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<void> {
|
|
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<PublicRecord[]> {
|
|
return this.api<PublicRecord[]>("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;
|
|
}
|
|
}
|