baa-conductor


baa-conductor / scripts / ops
im_wower  ·  2026-03-22

cloudflare-dns-plan.mjs

  1#!/usr/bin/env node
  2
  3import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
  4import { dirname, resolve } from "node:path";
  5import {
  6  buildDesiredDnsRecords,
  7  buildHostInventory,
  8  loadOpsConfig,
  9  parseCliArgs,
 10  resolvePath,
 11} from "./lib/ops-config.mjs";
 12
 13const usage = `Usage:
 14  node scripts/ops/cloudflare-dns-plan.mjs [--env PATH] [--fetch-current] [--format text|json]
 15                                           [--output PATH] [--emit-shell PATH]
 16
 17Behavior:
 18  - Without --fetch-current, only render the desired DNS records.
 19  - With --fetch-current, call Cloudflare GET endpoints and diff against the desired records.
 20  - This tool never sends POST/PATCH/DELETE requests. --emit-shell only writes a preview shell script.`;
 21
 22async function main() {
 23  const args = parseCliArgs(process.argv.slice(2));
 24
 25  if (args.help) {
 26    console.log(usage);
 27    return;
 28  }
 29
 30  const config = loadOpsConfig(args.env);
 31  const desiredRecords = buildDesiredDnsRecords(config);
 32  const hostInventory = buildHostInventory(config);
 33
 34  let currentRecords = [];
 35  let plan = buildDesiredOnlyPlan(desiredRecords);
 36
 37  if (args.fetchCurrent) {
 38    currentRecords = await fetchCurrentRecords(config, hostInventory.map((host) => host.hostname));
 39    plan = buildDiffPlan(desiredRecords, currentRecords);
 40  }
 41
 42  if (args.emitShell) {
 43    const shellPath = resolvePath(args.emitShell);
 44    writePreviewShell(shellPath, config, plan);
 45  }
 46
 47  const outputPayload = {
 48    env_path: config.envPath,
 49    zone_name: config.cloudflare.zoneName,
 50    zone_id: config.cloudflare.zoneId,
 51    fetch_current: Boolean(args.fetchCurrent),
 52    desired_records: desiredRecords,
 53    current_records: currentRecords,
 54    plan,
 55  };
 56
 57  if (args.output) {
 58    const outputPath = resolvePath(args.output);
 59    mkdirSync(resolve(outputPath, ".."), { recursive: true });
 60    writeFileSync(outputPath, `${JSON.stringify(outputPayload, null, 2)}\n`, "utf8");
 61  }
 62
 63  if (args.format === "json") {
 64    console.log(JSON.stringify(outputPayload, null, 2));
 65    return;
 66  }
 67
 68  console.log(formatTextOutput(config, desiredRecords, plan, Boolean(args.fetchCurrent), args.emitShell));
 69}
 70
 71function buildDesiredOnlyPlan(desiredRecords) {
 72  return desiredRecords.map((record) => ({
 73    action: "desired",
 74    target: record,
 75    reason: "Desired state preview only. Re-run with --fetch-current to diff against Cloudflare.",
 76  }));
 77}
 78
 79function buildDiffPlan(desiredRecords, currentRecords) {
 80  const recordsByKey = new Map();
 81
 82  for (const record of currentRecords) {
 83    const key = `${record.type}:${record.name}`;
 84    const existing = recordsByKey.get(key) ?? [];
 85    existing.push(record);
 86    recordsByKey.set(key, existing);
 87  }
 88
 89  const plan = [];
 90  const handledRecordIds = new Set();
 91
 92  for (const desired of desiredRecords) {
 93    const key = `${desired.type}:${desired.hostname}`;
 94    const candidates = [...(recordsByKey.get(key) ?? [])];
 95
 96    if (candidates.length === 0) {
 97      plan.push({
 98        action: "create",
 99        target: desired,
100        reason: "No existing record matched this hostname and type.",
101      });
102      continue;
103    }
104
105    const exactMatchIndex = candidates.findIndex((candidate) => recordMatches(candidate, desired));
106
107    if (exactMatchIndex >= 0) {
108      const [exactMatch] = candidates.splice(exactMatchIndex, 1);
109      handledRecordIds.add(exactMatch.id);
110      plan.push({
111        action: "noop",
112        current: exactMatch,
113        target: desired,
114        reason: "Existing record already matches the desired state.",
115      });
116
117      for (const duplicate of candidates) {
118        handledRecordIds.add(duplicate.id);
119        plan.push({
120          action: "delete",
121          current: duplicate,
122          reason: "Duplicate record for the same hostname and type.",
123        });
124      }
125
126      continue;
127    }
128
129    const [current, ...duplicates] = candidates;
130    handledRecordIds.add(current.id);
131
132    plan.push({
133      action: "update",
134      current,
135      target: desired,
136      reason: "Hostname and type exist, but content/proxied/ttl differ.",
137    });
138
139    for (const duplicate of duplicates) {
140      handledRecordIds.add(duplicate.id);
141      plan.push({
142        action: "delete",
143        current: duplicate,
144        reason: "Duplicate record for the same hostname and type.",
145      });
146    }
147  }
148
149  for (const current of currentRecords) {
150    if (handledRecordIds.has(current.id)) {
151      continue;
152    }
153
154    plan.push({
155      action: "delete",
156      current,
157      reason: "Managed hostname has no desired record for this type.",
158    });
159  }
160
161  return plan;
162}
163
164async function fetchCurrentRecords(config, hostnames) {
165  const token = process.env[config.cloudflare.apiTokenEnv];
166
167  if (!token) {
168    throw new Error(`Missing Cloudflare token in env var ${config.cloudflare.apiTokenEnv}`);
169  }
170
171  if (!config.cloudflare.zoneId || config.cloudflare.zoneId.startsWith("REPLACE_WITH_")) {
172    throw new Error("Set BAA_CF_ZONE_ID before using --fetch-current.");
173  }
174
175  const uniqueHostnames = Array.from(new Set(hostnames));
176  const currentRecords = [];
177
178  for (const hostname of uniqueHostnames) {
179    const url = new URL(`https://api.cloudflare.com/client/v4/zones/${config.cloudflare.zoneId}/dns_records`);
180    url.searchParams.set("name", hostname);
181    url.searchParams.set("per_page", "100");
182
183    const response = await fetch(url, {
184      headers: {
185        Authorization: `Bearer ${token}`,
186        "Content-Type": "application/json",
187      },
188    });
189
190    if (!response.ok) {
191      throw new Error(`Cloudflare GET failed for ${hostname}: ${response.status} ${response.statusText}`);
192    }
193
194    const payload = await response.json();
195
196    if (!payload.success) {
197      throw new Error(`Cloudflare GET returned an error for ${hostname}: ${JSON.stringify(payload.errors)}`);
198    }
199
200    for (const result of payload.result) {
201      if (result.type === "A" || result.type === "AAAA") {
202        currentRecords.push(result);
203      }
204    }
205  }
206
207  return currentRecords;
208}
209
210function recordMatches(current, desired) {
211  return (
212    current.type === desired.type &&
213    current.name === desired.hostname &&
214    current.content === desired.content &&
215    Boolean(current.proxied) === Boolean(desired.proxied) &&
216    Number(current.ttl) === Number(desired.ttl)
217  );
218}
219
220function writePreviewShell(shellPath, config, plan) {
221  mkdirSync(dirname(shellPath), { recursive: true });
222
223  const lines = [
224    "#!/usr/bin/env bash",
225    "set -euo pipefail",
226    "",
227    `: "\${${config.cloudflare.apiTokenEnv}:?export ${config.cloudflare.apiTokenEnv} first}"`,
228    `ZONE_ID=${shellQuote(config.cloudflare.zoneId)}`,
229    "",
230    "# Preview script only. Review before running any curl command.",
231  ];
232
233  let emittedCommand = false;
234
235  for (const item of plan) {
236    if (item.action === "create") {
237      emittedCommand = true;
238      lines.push(`# ${item.reason}`);
239      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "POST", "$ZONE_ID", null, toPayload(item.target)));
240      lines.push("");
241      continue;
242    }
243
244    if (item.action === "update") {
245      emittedCommand = true;
246      lines.push(`# ${item.reason}`);
247      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "PATCH", "$ZONE_ID", item.current.id, toPayload(item.target)));
248      lines.push("");
249      continue;
250    }
251
252    if (item.action === "delete") {
253      emittedCommand = true;
254      lines.push(`# ${item.reason}`);
255      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "DELETE", "$ZONE_ID", item.current.id, null));
256      lines.push("");
257    }
258  }
259
260  if (!emittedCommand) {
261    lines.push("echo 'No create/update/delete operations were generated.'");
262  }
263
264  writeFileSync(shellPath, `${lines.join("\n")}\n`, "utf8");
265  chmodSync(shellPath, 0o755);
266}
267
268function buildCurlCommand(tokenEnvName, method, zoneIdExpression, recordId, payload) {
269  const endpoint = recordId
270    ? `https://api.cloudflare.com/client/v4/zones/${zoneIdExpression}/dns_records/${recordId}`
271    : `https://api.cloudflare.com/client/v4/zones/${zoneIdExpression}/dns_records`;
272  const parts = [
273    `curl -sS -X ${method}`,
274    `  -H "Authorization: Bearer $${tokenEnvName}"`,
275    '  -H "Content-Type: application/json"',
276    `  "${endpoint}"`,
277  ];
278
279  if (payload) {
280    parts.push(`  --data ${shellQuote(JSON.stringify(payload))}`);
281  }
282
283  return parts.join(" \\\n");
284}
285
286function toPayload(record) {
287  return {
288    type: record.type,
289    name: record.hostname,
290    content: record.content,
291    proxied: record.proxied,
292    ttl: record.ttl,
293    comment: record.comment,
294  };
295}
296
297function shellQuote(value) {
298  return `'${String(value).replace(/'/g, `'\\''`)}'`;
299}
300
301function formatTextOutput(config, desiredRecords, plan, fetchedCurrent, emitShellPath) {
302  const lines = [];
303
304  lines.push(`Zone: ${config.cloudflare.zoneName}`);
305  lines.push(`Inventory: ${config.envPath}`);
306  lines.push("");
307  lines.push("Desired DNS records:");
308
309  for (const record of desiredRecords) {
310    lines.push(`- ${record.type} ${record.hostname} -> ${record.content} proxied=${record.proxied} ttl=${record.ttl}`);
311  }
312
313  lines.push("");
314  lines.push(fetchedCurrent ? "Diff plan:" : "Plan preview:");
315
316  for (const item of plan) {
317    if (item.action === "desired") {
318      lines.push(`- desired ${item.target.type} ${item.target.hostname} -> ${item.target.content}`);
319      continue;
320    }
321
322    if (item.action === "noop") {
323      lines.push(`- noop ${item.target.type} ${item.target.hostname} already ${item.target.content}`);
324      continue;
325    }
326
327    if (item.action === "create") {
328      lines.push(`- create ${item.target.type} ${item.target.hostname} -> ${item.target.content}`);
329      continue;
330    }
331
332    if (item.action === "update") {
333      lines.push(`- update ${item.target.type} ${item.target.hostname}: ${item.current.content} -> ${item.target.content}`);
334      continue;
335    }
336
337    if (item.action === "delete") {
338      lines.push(`- delete ${item.current.type} ${item.current.name} -> ${item.current.content}`);
339    }
340  }
341
342  lines.push("");
343  lines.push("Safety:");
344  lines.push("- This tool never writes DNS records by itself.");
345
346  if (fetchedCurrent) {
347    lines.push("- Cloudflare API access was read-only GET for the compared records.");
348  } else {
349    lines.push("- Re-run with --fetch-current after exporting the Cloudflare token to compare against live records.");
350  }
351
352  if (emitShellPath) {
353    lines.push(`- Preview curl script written to ${resolvePath(emitShellPath)}.`);
354  }
355
356  return lines.join("\n");
357}
358
359main().catch((error) => {
360  console.error(`cloudflare-dns-plan failed: ${error.message}`);
361  process.exitCode = 1;
362});