Files
universe/scripts/dns-sync.sh
T
zeekayandHanzo Dev 0e40ce9c8d dns: declare mpc.zoo.cloud, and let the sync use the credential we have
The zoo MPC nodes have served their API for as long as they have been up, with
no name pointing at them. Nothing in an Ingress creates a record and no
external-dns runs here, so the host existed only as far as the cluster.

dns-sync.sh could not have created it either: it spoke only Bearer tokens, and
what the estate has deployed for cert-manager is a global key with an email. It
now takes whichever is present, preferring the token, so one credential serves
both jobs instead of a second one being minted to do the same work.

Proxied, like every other zoo.cloud host that resolves. The origin serves the
ingress default certificate, so a direct record reaches a server whose
certificate no client accepts -- on this zone TLS is terminated at the edge or
it is not terminated at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-16 14:22:55 -07:00

179 lines
6.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# dns-sync.sh — Sync zone YAML files to Cloudflare DNS
#
# Reads zone YAMLs from k8s/dns/ and pushes A/CNAME records to Cloudflare.
# Skips proxied records (managed via Cloudflare UI).
# Only updates records that have drifted.
#
# Usage:
# ./scripts/dns-sync.sh # Dry run (show what would change)
# ./scripts/dns-sync.sh --apply # Apply changes to Cloudflare
# ./scripts/dns-sync.sh --zone lux-network # Sync single zone
#
# Prerequisites:
# CF_API_TOKEN env var (Cloudflare API token with DNS edit permission)
# python3 with yaml module (pip install pyyaml)
set -euo pipefail
DNS_DIR="$(cd "$(dirname "$0")/../k8s/dns" && pwd)"
DRY_RUN=true
SINGLE_ZONE=""
for arg in "$@"; do
case $arg in
--apply) DRY_RUN=false ;;
--zone=*) SINGLE_ZONE="${arg#*=}" ;;
--zone) shift; SINGLE_ZONE="${1:-}" ;;
esac
done
CF_TOKEN="${CF_API_TOKEN:-${CLOUDFLARE_DNS_TOKEN:-}}"
CF_EMAIL="${CF_API_EMAIL:-}"
CF_KEY="${CF_API_KEY:-}"
if [ -z "$CF_TOKEN" ] && [ -z "$CF_KEY" ] && [ "$DRY_RUN" = false ]; then
echo "ERROR: --apply needs either CF_API_TOKEN, or CF_API_EMAIL with CF_API_KEY"
exit 1
fi
echo "=== DNS Sync $([ "$DRY_RUN" = true ] && echo '(DRY RUN)' || echo '(APPLYING)') ==="
echo "Zone dir: $DNS_DIR"
echo ""
TOTAL_CHECKED=0
TOTAL_DRIFTED=0
TOTAL_UPDATED=0
for zone_file in "$DNS_DIR"/*.yaml; do
zone_name=$(basename "$zone_file" .yaml)
if [ -n "$SINGLE_ZONE" ] && [ "$zone_name" != "$SINGLE_ZONE" ]; then
continue
fi
# Parse zone YAML
zone_data=$(python3 -c "
import yaml, json, sys
with open('$zone_file') as f:
d = yaml.safe_load(f)
if d:
print(json.dumps({'zone': d.get('zone',''), 'zone_id': d.get('zone_id',''), 'records': d.get('records',[])}))
else:
print('{}')
" 2>/dev/null)
zone=$(echo "$zone_data" | python3 -c "import sys,json; print(json.load(sys.stdin).get('zone',''))")
zone_id=$(echo "$zone_data" | python3 -c "import sys,json; print(json.load(sys.stdin).get('zone_id',''))")
if [ -z "$zone" ] || [ -z "$zone_id" ]; then
echo "SKIP: $zone_name (no zone/zone_id)"
continue
fi
echo "--- $zone (ID: ${zone_id:0:8}...) ---"
# Process each record
echo "$zone_data" | python3 -c "
import sys, json, subprocess
data = json.load(sys.stdin)
zone = data['zone']
zone_id = data['zone_id']
records = data.get('records', [])
dry_run = '$DRY_RUN' == 'true'
token = '$CF_TOKEN'
cf_email = '$CF_EMAIL'
cf_key = '$CF_KEY'
checked = 0
drifted = 0
updated = 0
for r in records:
rtype = r.get('type', '')
name = r.get('name', '')
content = r.get('content', '')
proxied = r.get('proxied', False)
if rtype not in ('A', 'CNAME'):
continue
if name == '@':
continue # Skip apex (multi-record)
if proxied:
continue # Skip proxied (CF manages the IP)
# Resolve FQDN
fqdn = f'{name}.{zone}' if name != zone else zone
# Check live DNS
try:
result = subprocess.run(['dig', '+short', fqdn, '@1.1.1.1'], capture_output=True, text=True, timeout=5)
live = result.stdout.strip().split('\n')[0] if result.stdout.strip() else ''
except:
live = ''
checked += 1
if live == content:
continue # No drift
drifted += 1
print(f' DRIFT: {fqdn} live={live or \"(none)\"} yaml={content}')
if not dry_run and (token or (cf_email and cf_key)):
# Find existing record ID
import urllib.request
# Cloudflare takes either a scoped API token or the account's global key,
# and which one the estate has is not this script's choice to make. A token
# is preferred because it can be scoped to one zone; the global key is
# accepted so the credential already deployed for cert-manager can drive a
# sync without a second credential being minted to do the same job.
if token:
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
else:
headers = {'X-Auth-Email': cf_email, 'X-Auth-Key': cf_key, 'Content-Type': 'application/json'}
# List records matching this name
url = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type={rtype}&name={fqdn}'
req = urllib.request.Request(url, headers=headers)
try:
resp = urllib.request.urlopen(req, timeout=10)
cf_data = json.loads(resp.read())
cf_records = cf_data.get('result', [])
if cf_records:
# Update existing
rec_id = cf_records[0]['id']
patch_url = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec_id}'
patch_data = json.dumps({'content': content, 'proxied': proxied}).encode()
patch_req = urllib.request.Request(patch_url, data=patch_data, headers=headers, method='PATCH')
patch_resp = urllib.request.urlopen(patch_req, timeout=10)
patch_result = json.loads(patch_resp.read())
if patch_result.get('success'):
print(f' UPDATED: {fqdn} -> {content}')
updated += 1
else:
print(f' FAILED: {patch_result.get(\"errors\", \"unknown\")}')
else:
# Create new
create_url = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records'
create_data = json.dumps({'type': rtype, 'name': fqdn, 'content': content, 'proxied': proxied}).encode()
create_req = urllib.request.Request(create_url, data=create_data, headers=headers, method='POST')
create_resp = urllib.request.urlopen(create_req, timeout=10)
create_result = json.loads(create_resp.read())
if create_result.get('success'):
print(f' CREATED: {fqdn} -> {content}')
updated += 1
else:
print(f' FAILED: {create_result.get(\"errors\", \"unknown\")}')
except Exception as e:
print(f' ERROR: {e}')
print(f' Checked: {checked}, Drifted: {drifted}, Updated: {updated}')
" 2>/dev/null
echo ""
done
echo "=== Done ==="
[ "$DRY_RUN" = true ] && echo "Run with --apply to push changes to Cloudflare"