← back to Dw Yolo Loop
scripts/cf-dns-snapshot/cf-dns-snapshot.js
139 lines
#!/usr/bin/env node
/**
* Cloudflare DNS/zone drift snapshotter — READ-ONLY export + diff vs last commit.
*
* 200+ (today 35) CF zones are hand-edited with zero change history; a fat-fingered
* proxy toggle or clobbered MX/SPF/DKIM silently breaks a site or inbound mail for
* days. This serializes every zone's DNS records to a deterministic git-tracked JSON,
* diffs against the previously committed snapshot (git HEAD), and raises a TRIPWIRE on
* any change to mail-auth records (MX / SPF / DKIM / DMARC) on protected domains.
*
* CF access is GET-only (zones + dns_records). The only writes are the local snapshot
* JSON + report. NO DNS writes. Cost: $0.
*
* node cf-dns-snapshot.js # snapshot + diff vs HEAD + report
* After running, `git add` the snapshot so the NEXT run can diff against it.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const TOKEN = (fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8')
.match(/^CLOUDFLARE_API_TOKEN=(.+)$/m) || [])[1]?.trim();
if (!TOKEN) { console.error('no CLOUDFLARE_API_TOKEN'); process.exit(1); }
const DIR = __dirname;
const SNAP_REL = 'scripts/cf-dns-snapshot/snapshots/cf-zones-latest.json';
const SNAP_ABS = path.join(process.env.HOME, 'Projects/designerwallcoverings', SNAP_REL);
const REPORT = path.join(process.env.HOME, '.claude/yolo-queue', `cf-dns-drift-${new Date().toISOString().slice(0,10)}.md`);
// Protected domains: a mail-auth change here is the catastrophic case.
const PROTECTED = [/designerwallcoverings/i, /wallco/i, /sdcc/i, /agentabrams/i, /philipperomano/i];
const MAIL_TYPES = new Set(['MX', 'TXT']); // SPF/DKIM/DMARC live in TXT
const isMailAuth = (r) => r.type === 'MX' || (r.type === 'TXT' && /v=spf1|v=DKIM1|v=DMARC1|_dmarc|_domainkey/i.test(r.name + ' ' + r.content));
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function cf(pathq) {
for (let a = 0; a < 5; a++) {
try {
const res = await fetch(`https://api.cloudflare.com/client/v4${pathq}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (res.status === 429) { await sleep(2000 * (a + 1)); continue; }
const j = await res.json();
if (!j.success) throw new Error(JSON.stringify(j.errors).slice(0, 160));
return j;
} catch (e) { if (a === 4) throw e; await sleep(1500 * (a + 1)); }
}
}
async function allZones() {
const out = []; let page = 1;
while (true) {
const j = await cf(`/zones?per_page=50&page=${page}`);
out.push(...j.result);
if (page >= (j.result_info.total_pages || 1)) break;
page++;
}
return out;
}
async function zoneRecords(id) {
const out = []; let page = 1;
while (true) {
const j = await cf(`/zones/${id}/dns_records?per_page=100&page=${page}`);
out.push(...j.result);
if (page >= (j.result_info.total_pages || 1)) break;
page++;
}
// deterministic shape, sorted
return out.map(r => ({ type: r.type, name: r.name, content: r.content, proxied: !!r.proxied, ttl: r.ttl, priority: r.priority ?? null }))
.sort((a, b) => (a.type + a.name + a.content).localeCompare(b.type + b.name + b.content));
}
function loadPrior() {
try { return JSON.parse(execSync(`git show HEAD:${SNAP_REL} 2>/dev/null`, { cwd: path.join(process.env.HOME, 'Projects/designerwallcoverings'), encoding: 'utf8' })); }
catch (_) { return null; }
}
function diff(prior, current) {
// returns { added:[], removed:[], zone-level } keyed by zone
const changes = [];
const allZoneNames = new Set([...Object.keys(prior?.zones || {}), ...Object.keys(current.zones)]);
for (const z of allZoneNames) {
const p = (prior?.zones?.[z] || []).map(r => JSON.stringify(r));
const c = (current.zones[z] || []).map(r => JSON.stringify(r));
const pset = new Set(p), cset = new Set(c);
const added = c.filter(x => !pset.has(x)).map(JSON.parse);
const removed = p.filter(x => !cset.has(x)).map(JSON.parse);
if (added.length || removed.length) changes.push({ zone: z, added, removed });
}
return changes;
}
(async () => {
const zones = await allZones();
const snap = { generated_at: new Date().toISOString(), zone_count: zones.length, zones: {} };
for (const z of zones) { snap.zones[z.name] = await zoneRecords(z.id); }
const prior = loadPrior();
const changes = prior ? diff(prior, snap) : null;
// tripwire: mail-auth changes on protected domains
const tripwires = [];
if (changes) for (const ch of changes) {
if (!PROTECTED.some(rx => rx.test(ch.zone))) continue;
const mailChanged = [...ch.added, ...ch.removed].filter(isMailAuth);
if (mailChanged.length) tripwires.push({ zone: ch.zone, records: mailChanged });
}
fs.mkdirSync(path.dirname(SNAP_ABS), { recursive: true });
fs.writeFileSync(SNAP_ABS, JSON.stringify(snap, null, 2));
const totalRecords = Object.values(snap.zones).reduce((n, a) => n + a.length, 0);
let md = `# Cloudflare DNS drift — ${new Date().toISOString().slice(0,16)}\n\n`;
md += `Zones: **${zones.length}** · DNS records: **${totalRecords}** · Snapshot: \`${SNAP_REL}\`\n\n`;
if (!prior) {
md += `**First run — baseline captured.** No prior committed snapshot to diff against. ` +
`Commit \`${SNAP_REL}\` so the next run detects drift.\n`;
console.log(`[cf-dns-snapshot] BASELINE — ${zones.length} zones, ${totalRecords} records. Commit the snapshot to enable drift detection.`);
} else {
md += `## Drift vs last commit\n`;
if (!changes.length) { md += `🟢 No changes since last snapshot.\n`; console.log(`[cf-dns-snapshot] PASS — no drift across ${zones.length} zones.`); }
else {
md += `🟠 **${changes.length} zone(s) changed.**\n\n`;
for (const ch of changes) {
md += `### ${ch.zone}\n`;
for (const r of ch.removed) md += `- \`- ${r.type} ${r.name} → ${r.content}${r.proxied?' (proxied)':''}\`\n`;
for (const r of ch.added) md += `- \`+ ${r.type} ${r.name} → ${r.content}${r.proxied?' (proxied)':''}\`\n`;
}
console.log(`[cf-dns-snapshot] DRIFT — ${changes.length} zone(s) changed.`);
}
if (tripwires.length) {
md += `\n## 🔴 TRIPWIRE — mail-auth change on a PROTECTED domain\n`;
for (const t of tripwires) { md += `### ${t.zone}\n`; for (const r of t.records) md += `- ${r.type} ${r.name} → ${r.content}\n`; }
console.log(`[cf-dns-snapshot] 🔴 ${tripwires.length} PROTECTED mail-auth tripwire(s)!`);
}
}
fs.mkdirSync(path.dirname(REPORT), { recursive: true });
fs.writeFileSync(REPORT, md);
console.log(`Report: ${REPORT}\nSnapshot: ${SNAP_ABS}`);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });