audit-logger: the assigned-module manifest (ADR 0048) #2
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user