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