128 lines
5.1 KiB
TypeScript
128 lines
5.1 KiB
TypeScript
// cloudflare-dns's own code (novox/hq ADR 0039). It provides the mesh `public-dns` interface
|
|
// (ADR 0044): 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 0046), 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<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;
|
|
}
|
|
}
|
|
|
|
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 {};
|
|
}
|
|
}
|