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]>
179 lines
6.2 KiB
Bash
Executable File
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"
|