← 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 ""