← back to Coming Soon Template
scripts/godaddy_ns_api.js
106 lines
#!/usr/bin/env node
/* Flip GoDaddy nameservers -> Cloudflare via the GoDaddy API.
* Replaces the fragile Playwright UI-scraper (godaddy_flip.js): the API
* PATCH /v1/domains/{domain} { nameServers } is deterministic and needs
* no interactive login.
*
* Only flips domains that (a) have a Cloudflare zone with cf_ns and
* (b) have a live Cloudflare Pages project — so project-limit casualties
* (no project, no DNS record) are NOT flipped and never go dark.
* Domains with live MX in zone-setup.json are HELD BACK (email safety).
*
* Usage:
* node godaddy_ns_api.js # dry run — prints the plan, changes nothing
* node godaddy_ns_api.js --apply # perform the nameserver flip
*
* Writes data/ns-flip.json.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const ENV = path.join(os.homedir(), 'Projects/secrets-manager/.env');
const ROOT = path.join(os.homedir(), 'Projects/coming-soon-template');
const OUT = path.join(ROOT, 'data/ns-flip.json');
function envVal(key) {
const line = fs.readFileSync(ENV, 'utf8').split('\n').find(l => l.startsWith(key + '='));
return line ? line.slice(key.length + 1).trim() : '';
}
const GD_KEY = envVal('GODADDY_API_KEY');
const GD_SECRET = envVal('GODADDY_API_SECRET');
const GD_PAT = envVal('GODADDY_PAT');
const CF_TOK = envVal('CLOUDFLARE_API_TOKEN');
const ACCT = envVal('CLOUDFLARE_ACCOUNT_ID');
if (!GD_PAT && (!GD_KEY || !GD_SECRET)) { console.error('missing GODADDY_PAT or GODADDY_API_KEY / GODADDY_API_SECRET in', ENV); process.exit(1); }
const GD = 'https://api.godaddy.com/v1';
// Prefer a Bearer PAT when present (TK-10 key rotation); else legacy sso-key.
const GD_AUTH = GD_PAT ? `Bearer ${GD_PAT}` : `sso-key ${GD_KEY}:${GD_SECRET}`;
const GD_HEADERS = { 'Authorization': GD_AUTH, 'Content-Type': 'application/json' };
const APPLY = process.argv.includes('--apply');
async function gd(method, urlPath, body) {
const res = await fetch(GD + urlPath, {
method, headers: GD_HEADERS, body: body ? JSON.stringify(body) : undefined,
});
let json = null;
const txt = await res.text();
if (txt) { try { json = JSON.parse(txt); } catch { json = { raw: txt }; } }
return { http: res.status, json };
}
// A domain is safe to flip only if its Cloudflare zone already has an apex
// DNS record — i.e. there is something to serve (Pages CNAME or Worker
// custom-domain record). Flipping a zone with no record makes the site dark.
async function hasApexRecord(zid, domain) {
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zid}/dns_records?name=${domain}`,
{ headers: { 'Authorization': `Bearer ${CF_TOK}` } });
const j = await res.json();
return (j.result || []).some(r => r.name === domain);
}
const isCfNs = ns => Array.isArray(ns) && ns.every(h => /\.ns\.cloudflare\.com$/i.test(h));
async function main() {
const zones = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/zone-setup.json'), 'utf8'));
const results = [];
console.log(APPLY ? '=== APPLYING nameserver flip ===\n' : '=== DRY RUN (use --apply to flip) ===\n');
for (const z of zones) {
const domain = z.domain;
const project = domain.replace(/\.com$/, '');
const ns = z.cf_ns || [];
if (z.live_mx) { console.log(`HOLD ${domain} — live MX (${z.live_mx})`); results.push({ domain, status: 'held_live_mx' }); continue; }
if (ns.length < 2) { console.log(`SKIP ${domain} — zone has no cf_ns`); results.push({ domain, status: 'skipped_no_ns' }); continue; }
if (!z.zone_id || !(await hasApexRecord(z.zone_id, domain))) {
console.log(`SKIP ${domain} — zone has no apex DNS record (would go dark)`);
results.push({ domain, status: 'skipped_no_record' }); continue;
}
const cur = await gd('GET', `/domains/${domain}`);
const curNs = (cur.json && cur.json.nameServers) || [];
if (isCfNs(curNs)) { console.log(`OK ${domain} — already on Cloudflare NS`); results.push({ domain, status: 'already_cf', ns: curNs }); continue; }
if (!APPLY) { console.log(`FLIP ${domain} ${curNs.join(',')} -> ${ns.join(',')}`); results.push({ domain, status: 'would_flip', from: curNs, to: ns }); continue; }
const upd = await gd('PATCH', `/domains/${domain}`, { nameServers: ns });
if (upd.http === 200 || upd.http === 204) {
console.log(`OK ${domain} flipped -> ${ns.join(', ')}`);
results.push({ domain, status: 'flipped', from: curNs, to: ns });
} else {
const msg = (upd.json && (upd.json.message || JSON.stringify(upd.json))) || `HTTP ${upd.http}`;
console.log(`FAIL ${domain} [HTTP ${upd.http}] ${msg}`);
results.push({ domain, status: 'flip_failed', http: upd.http, error: msg });
}
}
fs.writeFileSync(OUT, JSON.stringify(results, null, 2));
const flipped = results.filter(r => r.status === 'flipped').length;
const wf = results.filter(r => r.status === 'would_flip').length;
console.log(`\n--- ${APPLY ? `${flipped} flipped` : `${wf} would flip`} --- wrote ${OUT}`);
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });