diff --git a/modules/cloudflare-dns/client.ts b/modules/cloudflare-dns/client.ts new file mode 100644 index 0000000..3c1d270 --- /dev/null +++ b/modules/cloudflare-dns/client.ts @@ -0,0 +1,105 @@ +// 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(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; + } +} diff --git a/modules/cloudflare-dns/module.json b/modules/cloudflare-dns/module.json new file mode 100644 index 0000000..58e4806 --- /dev/null +++ b/modules/cloudflare-dns/module.json @@ -0,0 +1,61 @@ +{ + "module": "cloudflare-dns", + "version": "1", + "provides": [ + { + "name": "public-dns", + "scope": "mesh" + } + ], + "serves": { + "public-dns": {} + }, + "grants": { + "public-dns": "/var/lib/cloudflare-dns/grants" + }, + "receives": { + "public-dns": "/var/lib/cloudflare-dns/grants/mesh.json" + }, + "own-secrets": { + "token": "/var/lib/cloudflare-dns/token", + "broker": "/var/lib/cloudflare-dns/broker" + }, + "emits": [ + "module.cloudflare-dns.record.created", + "module.cloudflare-dns.record.removed" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/cloudflare-dns", + "mode": "0700" + }, + { + "id": "grants", + "type": "directory", + "path": "/var/lib/cloudflare-dns/grants", + "mode": "0700" + }, + { + "id": "provisioner", + "type": "container", + "name": "mesh-provision-cloudflare-dns", + "image": "mesh-provision-cloudflare-dns@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "env": { + "GRANTS": "/grants", + "MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token", + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_CLOUDFLARE_ZONE_ID": "", + "MESH_PUBLIC_DOMAIN": "", + "MESH_PUBLIC_INGRESS": "" + }, + "volumes": [ + "/var/lib/cloudflare-dns/grants:/grants", + "/var/lib/cloudflare-dns/token:/run/secrets/token:ro", + "/var/lib/cloudflare-dns/broker:/run/secrets/broker:ro" + ] + } + ] +} diff --git a/modules/cloudflare-dns/package.json b/modules/cloudflare-dns/package.json new file mode 100644 index 0000000..01e230a --- /dev/null +++ b/modules/cloudflare-dns/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-cloudflare-dns", + "version": "0.1.0", + "description": "cloudflare-dns — a public-dns provider (ADR 0049): registers public names at Cloudflare. + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/cloudflare-dns/provisioner/index.ts b/modules/cloudflare-dns/provisioner/index.ts new file mode 100644 index 0000000..c9cfac3 --- /dev/null +++ b/modules/cloudflare-dns/provisioner/index.ts @@ -0,0 +1,43 @@ +// cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface +// (novox/hq ADR 0049). The reconcile loop, sealing and grant-file handling are the sdk harness's; +// this writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing +// it at the mesh's ingress, and remove it when the grant is withdrawn. +// +// The `public-dns` interface hands a consumer { fqdn, target, ttl } — a name that resolves publicly +// and what it resolves to. It is not a secret (a DNS record is public), so nothing is sealed beyond +// what the harness seals; the only secret is this module's own Cloudflare token, which never leaves. + +import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { CloudflareClient } from "../client.js"; + +const cloudflare = CloudflareClient.fromEnv(); + +runProvisioner("public-dns", { + async create(grant: Grant): Promise { + const fqdn = cloudflare.nameFor(grant.consumer); + await cloudflare.upsert(fqdn); + await announce("module.cloudflare-dns.record.created", { + name: fqdn, + target: cloudflare.ingress, + consumer: grant.consumer, + node: grant.node, + }); + return { fields: { fqdn, target: cloudflare.ingress, ttl: "300" } }; + }, + + async remove(grant: Grant): Promise { + const fqdn = cloudflare.nameFor(grant.consumer); + await cloudflare.remove(fqdn); + await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: grant.consumer, node: grant.node }); + }, +}); + +/** Emit best-effort: a broker hiccup must never fail or reverse a DNS change that already happened. */ +async function announce(type: string, body: unknown): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[cloudflare-dns] could not emit ${type}: ${err}`); + } +} diff --git a/modules/cloudflare-dns/tools/index.ts b/modules/cloudflare-dns/tools/index.ts new file mode 100644 index 0000000..529e7fd --- /dev/null +++ b/modules/cloudflare-dns/tools/index.ts @@ -0,0 +1,23 @@ +// cloudflare-dns's tool — the diagnostic: what public names the mesh currently publishes here. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { CloudflareClient } from "../client.js"; + +export function getCloudflareDnsTools(cloudflare: CloudflareClient): ToolDefinition[] { + return [ + { + name: "cloudflare_dns_records", + description: "The public DNS records in the mesh's zone — the names it currently publishes.", + input: {}, + run: async () => ({ domain: cloudflare.domain, ingress: cloudflare.ingress, records: await cloudflare.records() }), + }, + ]; +} + +registerModuleTools("cloudflare-dns", (env) => { + try { + return getCloudflareDnsTools(CloudflareClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/cloudflare-dns/tsconfig.json b/modules/cloudflare-dns/tsconfig.json new file mode 100644 index 0000000..c2a8df0 --- /dev/null +++ b/modules/cloudflare-dns/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts", + "provisioner/index.ts" + ] +} \ No newline at end of file