cloudflare-dns: a public-dns provider (ADR 0049)

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.
This commit is contained in:
2026-09-04 20:55:36 +02:00
parent 0e102dd350
commit d2d20a76b6
6 changed files with 262 additions and 0 deletions
+105
View File
@@ -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<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;
}
}
+61
View File
@@ -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"
]
}
]
}
+14
View File
@@ -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"
}
}
@@ -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<Credential> {
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<void> {
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<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[cloudflare-dns] could not emit ${type}: ${err}`);
}
}
+23
View File
@@ -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 [];
}
});
+16
View File
@@ -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"
]
}