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});