← back to NationalPaperHangers

dns/apply-cloudflare-zone.sh

382 lines

#!/usr/bin/env bash
# apply-cloudflare-zone.sh
# Creates a Cloudflare zone and DNS records for nationalpaperhangers.com.
#
# USAGE:
#   bash dns/apply-cloudflare-zone.sh            # DRY-RUN (default — prints curl calls, executes nothing)
#   bash dns/apply-cloudflare-zone.sh --commit   # EXECUTE API calls
#
# REQUIREMENTS:
#   - CF_API_TOKEN must be set in env with Zone:Edit + DNS:Edit permissions.
#     The script verifies token capabilities before proceeding.
#   - curl + python3 (for pretty-printing JSON) must be installed.
#
# The script is idempotent: it skips records that already exist.
# ─────────────────────────────────────────────────────────────────────────────

set -euo pipefail

# ─── Config ───────────────────────────────────────────────────────────────────
DOMAIN="nationalpaperhangers.com"
KAMATERA_IP="45.61.58.125"
CF_API="https://api.cloudflare.com/client/v4"

# ─── Protected domains — never touch these ────────────────────────────────────
PROTECTED_DOMAINS=(
  "designerwallcoverings.com"
  "studentdebtcrisis.org"
  "studentdebtcrisiscenter.org"
)

# ─── Color helpers ────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

log()    { echo -e "${CYAN}[INFO]${NC} $*"; }
ok()     { echo -e "${GREEN}[OK]${NC}   $*"; }
warn()   { echo -e "${YELLOW}[WARN]${NC} $*"; }
die()    { echo -e "${RED}[FAIL]${NC} $*" >&2; exit 1; }

# ─── Parse args ───────────────────────────────────────────────────────────────
COMMIT=false
for arg in "$@"; do
  [[ "$arg" == "--commit" ]] && COMMIT=true
done

if [[ "$COMMIT" == "false" ]]; then
  echo ""
  echo -e "${YELLOW}═══════════════════════════════════════════════════════${NC}"
  echo -e "${YELLOW}  DRY-RUN MODE — no API calls will be executed${NC}"
  echo -e "${YELLOW}  Re-run with --commit to apply changes${NC}"
  echo -e "${YELLOW}═══════════════════════════════════════════════════════${NC}"
  echo ""
fi

# ─── Guard: protected domain check ────────────────────────────────────────────
for protected in "${PROTECTED_DOMAINS[@]}"; do
  if [[ "$DOMAIN" == "$protected" ]]; then
    die "BLOCKED: $DOMAIN is on the DNS do-not-touch list. Exiting without changes."
  fi
done
ok "Domain $DOMAIN is not on the protected list."

# ─── Guard: CF_API_TOKEN must be set ──────────────────────────────────────────
if [[ -z "${CF_API_TOKEN:-}" ]]; then
  die "CF_API_TOKEN is not set. Export it before running:\n  export CF_API_TOKEN=<your-token>"
fi

# ─── Token capability check — must have Zone:Edit ─────────────────────────────
log "Verifying token capabilities via /user/tokens/verify ..."

VERIFY_RESP=$(curl -sf -X GET "${CF_API}/user/tokens/verify" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json") || die "Token verification request failed. Check CF_API_TOKEN."

TOKEN_STATUS=$(echo "$VERIFY_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('status','unknown'))" 2>/dev/null || echo "unknown")

if [[ "$TOKEN_STATUS" != "active" ]]; then
  die "Token is not active (status=$TOKEN_STATUS). Mint a new token and retry."
fi

# Check for Zone:Edit permission (policy effect = "allow", resource includes zone)
# The /user/tokens/verify endpoint does not enumerate permissions granularly,
# so we probe for zone-create capability by attempting a preflight list.
# A DNS:Edit-only token returns HTTP 403 on /zones POST attempts.
# We detect this by checking the token's permission list from /user/tokens/:<id>
# — but token ID is opaque from the bearer alone. Instead we attempt a safe
# /zones?name=<domain>&status=active GET, which succeeds on both DNS:Edit and
# Zone:Edit tokens, and then attempt zone creation only on --commit.
# The script therefore checks at commit time whether the create call is
# authorized and fails-fast with a clear error if not.

ZONES_CHECK=$(curl -sf -X GET "${CF_API}/zones?name=${DOMAIN}&status=active" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json") || die "Could not reach Cloudflare API. Check network and token."

EXISTING_ZONE_ID=$(echo "$ZONES_CHECK" | python3 -c \
  "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")

ok "Token is active and can query zones."

if [[ -n "$EXISTING_ZONE_ID" ]]; then
  ok "Zone already exists: $DOMAIN (id=$EXISTING_ZONE_ID)"
  ZONE_ID="$EXISTING_ZONE_ID"
  ZONE_CREATED=false
else
  log "Zone not found in Cloudflare — will create it."
  ZONE_ID=""
  ZONE_CREATED=false
fi

# ─── Helper: CF API call ──────────────────────────────────────────────────────
# Usage: cf_api METHOD PATH BODY
# In dry-run mode: prints the call. In commit mode: executes and returns response.
cf_api() {
  local method="$1"
  local path="$2"
  local body="${3:-}"

  if [[ "$COMMIT" == "false" ]]; then
    echo -e "  ${CYAN}[DRY-RUN]${NC} curl -s -X ${method} '${CF_API}${path}' \\"
    echo    "           -H 'Authorization: Bearer \$CF_API_TOKEN' \\"
    echo    "           -H 'Content-Type: application/json' \\"
    if [[ -n "$body" ]]; then
      echo  "           --data '${body}'"
    fi
    echo ""
    echo "DRYRUN_OK"
    return 0
  fi

  local resp
  resp=$(curl -sf -X "$method" "${CF_API}${path}" \
    -H "Authorization: Bearer ${CF_API_TOKEN}" \
    -H "Content-Type: application/json" \
    ${body:+--data "$body"}) || { echo "CURL_FAIL"; return 1; }

  echo "$resp"
}

# ─── Step 1: Create zone (if needed) ──────────────────────────────────────────
if [[ -z "$ZONE_ID" ]]; then
  log "Creating zone: $DOMAIN"

  # Read account ID from YAML comment block (expect user to fill it in),
  # or fall back to auto-detect from token's accessible accounts.
  ACCOUNT_RESP=$(curl -sf -X GET "${CF_API}/accounts" \
    -H "Authorization: Bearer ${CF_API_TOKEN}" \
    -H "Content-Type: application/json") || die "Could not list accounts. Token may lack Zone:Edit."

  ACCOUNT_ID=$(echo "$ACCOUNT_RESP" | python3 -c \
    "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")

  if [[ -z "$ACCOUNT_ID" ]]; then
    die "Could not determine Cloudflare account ID.\nThis usually means the token lacks Zone:Edit permission.\nMint a new token with Zone:Edit + DNS:Edit and retry."
  fi

  log "Account ID resolved: $ACCOUNT_ID"

  ZONE_BODY="{\"name\":\"${DOMAIN}\",\"account\":{\"id\":\"${ACCOUNT_ID}\"},\"jump_start\":false}"
  ZONE_RESP=$(cf_api POST "/zones" "$ZONE_BODY")

  if [[ "$ZONE_RESP" == "DRYRUN_OK" ]]; then
    ZONE_ID="DRY_RUN_ZONE_ID"
    ZONE_CREATED=true
  else
    # Check for permission error
    ZONE_SUCCESS=$(echo "$ZONE_RESP" | python3 -c \
      "import sys,json; d=json.load(sys.stdin); print(d.get('success','false'))" 2>/dev/null || echo "false")

    if [[ "$ZONE_SUCCESS" != "True" && "$ZONE_SUCCESS" != "true" ]]; then
      ERRMSG=$(echo "$ZONE_RESP" | python3 -c \
        "import sys,json; d=json.load(sys.stdin); print(d.get('errors',[])[0].get('message','unknown'))" 2>/dev/null || echo "unknown")
      # Specific guidance for the most common failure
      if echo "$ERRMSG" | grep -qi "permission\|not allowed\|forbidden"; then
        die "Zone creation failed: needs Zone:Edit perm\nError: $ERRMSG\nMint a new token with Zone:Edit permission (see cloudflare-zone.yaml for click-path)."
      fi
      die "Zone creation failed: $ERRMSG"
    fi

    ZONE_ID=$(echo "$ZONE_RESP" | python3 -c \
      "import sys,json; print(json.load(sys.stdin)['result']['id'])" 2>/dev/null || echo "")
    ZONE_CREATED=true
    ok "Zone created: $ZONE_ID"

    # After zone creation, Cloudflare assigns nameservers.
    # Print them so Steve knows what to set in GoDaddy.
    NS1=$(echo "$ZONE_RESP" | python3 -c \
      "import sys,json; ns=json.load(sys.stdin)['result'].get('name_servers',[]); print(ns[0] if ns else 'see CF dashboard')" 2>/dev/null || echo "see CF dashboard")
    NS2=$(echo "$ZONE_RESP" | python3 -c \
      "import sys,json; ns=json.load(sys.stdin)['result'].get('name_servers',[]); print(ns[1] if len(ns)>1 else 'see CF dashboard')" 2>/dev/null || echo "see CF dashboard")

    echo ""
    echo -e "${YELLOW}ACTION REQUIRED — Update GoDaddy Nameservers:${NC}"
    echo "  Log in to GoDaddy → My Products → nationalpaperhangers.com"
    echo "  DNS → Nameservers → I'll use my own nameservers"
    echo "  NS1: $NS1"
    echo "  NS2: $NS2"
    echo "  Save. Propagation: up to 48h."
    echo ""
  fi
fi

# ─── Helper: check if a DNS record already exists ─────────────────────────────
# Returns "EXISTS" if a record with matching type+name+content is found.
record_exists() {
  local zone_id="$1"
  local type="$2"
  local name="$3"
  local content="$4"

  [[ "$zone_id" == "DRY_RUN_ZONE_ID" ]] && echo "SKIP" && return

  local resp
  resp=$(curl -sf -X GET \
    "${CF_API}/zones/${zone_id}/dns_records?type=${type}&name=${name}.${DOMAIN}" \
    -H "Authorization: Bearer ${CF_API_TOKEN}" \
    -H "Content-Type: application/json") || { echo "ERROR"; return; }

  local found
  found=$(echo "$resp" | python3 -c "
import sys, json
data = json.load(sys.stdin)
records = data.get('result', [])
content_check = '${content}'.lower()
for r in records:
    if r.get('content','').lower() == content_check:
        print('EXISTS')
        sys.exit(0)
print('NOT_FOUND')
" 2>/dev/null || echo "NOT_FOUND")

  echo "$found"
}

# ─── Helper: create a DNS record ──────────────────────────────────────────────
create_record() {
  local type="$1"
  local name="$2"
  local content="$3"
  local proxied="${4:-false}"
  local ttl="${5:-3600}"
  local priority="${6:-}"
  local comment="${7:-}"

  local label="${type} ${name} → ${content}"

  # Idempotency check
  local exists
  exists=$(record_exists "$ZONE_ID" "$type" "$name" "$content")
  if [[ "$exists" == "EXISTS" ]]; then
    warn "SKIP (already exists): $label"
    return
  fi

  log "Creating record: $label"

  local body
  if [[ -n "$priority" ]]; then
    body=$(python3 -c "import json; print(json.dumps({
      'type':'${type}','name':'${name}','content':'${content}',
      'ttl':${ttl},'proxied':$(echo $proxied | tr '[:upper:]' '[:lower:]'),
      'priority':${priority},'comment':'${comment}'
    }))")
  else
    body=$(python3 -c "import json; print(json.dumps({
      'type':'${type}','name':'${name}','content':'${content}',
      'ttl':${ttl},'proxied':$(echo $proxied | tr '[:upper:]' '[:lower:]'),
      'comment':'${comment}'
    }))")
  fi

  local resp
  resp=$(cf_api POST "/zones/${ZONE_ID}/dns_records" "$body")

  if [[ "$resp" == "DRYRUN_OK" ]]; then
    return
  fi

  local success
  success=$(echo "$resp" | python3 -c \
    "import sys,json; print(json.load(sys.stdin).get('success',False))" 2>/dev/null || echo "false")

  if [[ "$success" == "True" || "$success" == "true" ]]; then
    ok "Created: $label"
  else
    local err
    err=$(echo "$resp" | python3 -c \
      "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0].get('message','unknown') if errs else 'unknown')" 2>/dev/null || echo "unknown")
    warn "FAILED to create $label: $err"
  fi
}

# ─── Step 2: Apply DNS records ────────────────────────────────────────────────
log "Applying DNS records to zone: $DOMAIN"

# A records — web (proxied)
create_record "A"   "@"   "$KAMATERA_IP" "true"  "1"    "" "Apex to Kamatera production server (proxied)"
create_record "A"   "www" "$KAMATERA_IP" "true"  "1"    "" "www to Kamatera production server (proxied)"

# MX records — Migadu (not proxied)
create_record "MX"  "@"   "aspmx1.migadu.com" "false" "3600" "10" "Migadu primary MX"
create_record "MX"  "@"   "aspmx2.migadu.com" "false" "3600" "20" "Migadu secondary MX"

# SPF record
create_record "TXT" "@"   "v=spf1 include:spf.migadu.com -all" "false" "3600" "" "SPF - Migadu authorized sender"

# DKIM CNAME delegates
create_record "CNAME" "key1._domainkey" "key1.nationalpaperhangers.com._domainkey.migadu.com" "false" "3600" "" "DKIM key1 - Migadu delegate"
create_record "CNAME" "key2._domainkey" "key2.nationalpaperhangers.com._domainkey.migadu.com" "false" "3600" "" "DKIM key2 - Migadu delegate"
create_record "CNAME" "key3._domainkey" "key3.nationalpaperhangers.com._domainkey.migadu.com" "false" "3600" "" "DKIM key3 - Migadu delegate"

# Migadu domain verification TXT
# IMPORTANT: replace the token value with what Migadu shows in its domain wizard
MIGADU_VERIFY_TOKEN="${MIGADU_VERIFY_TOKEN:-REPLACE_WITH_MIGADU_VERIFY_TOKEN}"
if [[ "$MIGADU_VERIFY_TOKEN" == "REPLACE_WITH_MIGADU_VERIFY_TOKEN" ]]; then
  warn "MIGADU_VERIFY_TOKEN is not set. Skipping domain verification TXT record."
  warn "Set it: export MIGADU_VERIFY_TOKEN=<token-from-migadu-admin> then re-run."
else
  create_record "TXT" "@" "hosted-email-verify=${MIGADU_VERIFY_TOKEN}" "false" "3600" "" "Migadu domain ownership verification"
fi

# DMARC — Phase 1 (p=none, monitor only)
create_record "TXT" "_dmarc" \
  "v=DMARC1; p=none; rua=mailto:info@designerwallcoverings.com; ruf=mailto:info@designerwallcoverings.com; fo=1; adkim=r; aspf=r" \
  "false" "3600" "" "DMARC Phase 1 - monitor only. Update p=none to p=quarantine after 14 days."

# ─── Step 3: Zone-level settings ─────────────────────────────────────────────
if [[ "$COMMIT" == "true" && -n "$ZONE_ID" && "$ZONE_ID" != "DRY_RUN_ZONE_ID" ]]; then
  log "Applying zone settings (HTTPS, min TLS) ..."

  # Always use HTTPS
  curl -sf -X PATCH "${CF_API}/zones/${ZONE_ID}/settings/always_use_https" \
    -H "Authorization: Bearer ${CF_API_TOKEN}" \
    -H "Content-Type: application/json" \
    --data '{"value":"on"}' > /dev/null && ok "Always Use HTTPS: on" || warn "Could not set Always Use HTTPS"

  # Minimum TLS 1.2
  curl -sf -X PATCH "${CF_API}/zones/${ZONE_ID}/settings/min_tls_version" \
    -H "Authorization: Bearer ${CF_API_TOKEN}" \
    -H "Content-Type: application/json" \
    --data '{"value":"1.2"}' > /dev/null && ok "Min TLS version: 1.2" || warn "Could not set min TLS"

  # SSL mode = full (strict requires origin cert — certbot must run first;
  # start with "full" and upgrade to "strict" after LE cert is confirmed)
  curl -sf -X PATCH "${CF_API}/zones/${ZONE_ID}/settings/ssl" \
    -H "Authorization: Bearer ${CF_API_TOKEN}" \
    -H "Content-Type: application/json" \
    --data '{"value":"full"}' > /dev/null && ok "SSL mode: full (upgrade to full_strict after LE cert)" || warn "Could not set SSL mode"

else
  log "[DRY-RUN] Would apply zone settings: always_use_https=on, min_tls=1.2, ssl=full"
fi

# ─── Summary ─────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}"
if [[ "$COMMIT" == "true" ]]; then
  echo -e "${GREEN}  DONE. Records applied to $DOMAIN${NC}"
  echo ""
  echo "  Next steps:"
  echo "  1. If zone was newly created: update GoDaddy NS to Cloudflare NS"
  echo "     (nameservers printed above; GoDaddy propagation up to 48h)"
  echo "  2. Set MIGADU_VERIFY_TOKEN env var and re-run --commit to add"
  echo "     the Migadu domain verification TXT (if skipped above)"
  echo "  3. In Migadu admin: add nationalpaperhangers.com, create"
  echo "     info@nationalpaperhangers.com mailbox"
  echo "  4. After LE cert lands on Kamatera: upgrade CF SSL to Full (Strict)"
  echo "     curl -X PATCH ...zones/\$ZONE_ID/settings/ssl --data '{\"value\":\"full_strict\"}'"
  echo "  5. Day 14: update DMARC from p=none to p=quarantine"
  echo "     (check aggregate reports at info@designerwallcoverings.com first)"
  echo "  6. Update George env: SMTP_HOST=smtp.migadu.com SMTP_PORT=587"
  echo "     SMTP_USER=info@nationalpaperhangers.com SMTP_PASS=<migadu-app-pw>"
else
  echo -e "${YELLOW}  DRY-RUN complete. No changes made.${NC}"
  echo -e "  Re-run with ${CYAN}--commit${NC} to apply."
fi
echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}"
echo ""