#!/usr/bin/env python3 """ dns-manager.py — Manage DNS records via Cloudflare API. Provides add/update/delete/list operations for DNS A records. Designed for fleet VPS nodes that need API-driven DNS management. Usage: python3 scripts/dns-manager.py list --zone alexanderwhitestone.com python3 scripts/dns-manager.py add --zone alexanderwhitestone.com --name forge --ip 143.198.27.163 python3 scripts/dns-manager.py update --zone alexanderwhitestone.com --name forge --ip 167.99.126.228 python3 scripts/dns-manager.py delete --zone alexanderwhitestone.com --name forge python3 scripts/dns-manager.py sync --config dns-records.yaml Config via env: CLOUDFLARE_API_TOKEN — API token with DNS:Edit permission CLOUDFLARE_ZONE_ID — Zone ID (auto-resolved if not set) Part of #692: Sovereign DNS management. """ import argparse import json import os import sys import urllib.request import urllib.error from pathlib import Path from typing import Any, Dict, List, Optional CF_API = "https://api.cloudflare.com/client/v4" # ── Auth ────────────────────────────────────────────────────────────────── def get_token() -> str: """Get Cloudflare API token from env or config.""" token = os.environ.get("CLOUDFLARE_API_TOKEN", "") if not token: token_path = Path.home() / ".config" / "cloudflare" / "token" if token_path.exists(): token = token_path.read_text().strip() if not token: print("ERROR: No Cloudflare API token found.", file=sys.stderr) print("Set CLOUDFLARE_API_TOKEN env var or create ~/.config/cloudflare/token", file=sys.stderr) sys.exit(1) return token def cf_request(method: str, path: str, token: str, data: dict = None) -> dict: """Make a Cloudflare API request.""" url = f"{CF_API}{path}" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } body = json.dumps(data).encode() if data else None req = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read().decode()) if not result.get("success", True): errors = result.get("errors", []) print(f"API error: {errors}", file=sys.stderr) sys.exit(1) return result except urllib.error.HTTPError as e: body = e.read().decode() if e.fp else "" print(f"HTTP {e.code}: {body[:500]}", file=sys.stderr) sys.exit(1) # ── Zone Resolution ────────────────────────────────────────────────────── def resolve_zone_id(zone_name: str, token: str) -> str: """Resolve zone name to zone ID.""" cached = os.environ.get("CLOUDFLARE_ZONE_ID", "") if cached: return cached result = cf_request("GET", f"/zones?name={zone_name}", token) zones = result.get("result", []) if not zones: print(f"ERROR: Zone '{zone_name}' not found", file=sys.stderr) sys.exit(1) return zones[0]["id"] # ── DNS Operations ─────────────────────────────────────────────────────── def list_records(zone_id: str, token: str, name_filter: str = "") -> List[dict]: """List DNS records in a zone.""" path = f"/zones/{zone_id}/dns_records?per_page=100" if name_filter: path += f"&name={name_filter}" result = cf_request("GET", path, token) return result.get("result", []) def find_record(zone_id: str, token: str, name: str, record_type: str = "A") -> Optional[dict]: """Find a specific DNS record.""" records = list_records(zone_id, token, name) for r in records: if r["name"] == name and r["type"] == record_type: return r return None def add_record(zone_id: str, token: str, name: str, ip: str, ttl: int = 300, proxied: bool = False) -> dict: """Add a new DNS A record.""" # Check if record already exists existing = find_record(zone_id, token, name) if existing: print(f"Record {name} already exists (IP: {existing['content']}). Use 'update' to change.") return existing data = { "type": "A", "name": name, "content": ip, "ttl": ttl, "proxied": proxied, } result = cf_request("POST", f"/zones/{zone_id}/dns_records", token, data) record = result["result"] print(f"Added: {record['name']} -> {record['content']} (ID: {record['id']})") return record def update_record(zone_id: str, token: str, name: str, ip: str, ttl: int = 300) -> dict: """Update an existing DNS A record.""" existing = find_record(zone_id, token, name) if not existing: print(f"Record {name} not found. Use 'add' to create it.") sys.exit(1) data = { "type": "A", "name": name, "content": ip, "ttl": ttl, "proxied": existing.get("proxied", False), } result = cf_request("PUT", f"/zones/{zone_id}/dns_records/{existing['id']}", token, data) record = result["result"] print(f"Updated: {record['name']} {existing['content']} -> {record['content']}") return record def delete_record(zone_id: str, token: str, name: str) -> bool: """Delete a DNS A record.""" existing = find_record(zone_id, token, name) if not existing: print(f"Record {name} not found.") return False cf_request("DELETE", f"/zones/{zone_id}/dns_records/{existing['id']}", token) print(f"Deleted: {name} ({existing['content']})") return True def sync_records(zone_id: str, token: str, config_path: str): """Sync DNS records from a YAML config file.""" try: import yaml except ImportError: print("ERROR: PyYAML required for sync. Install: pip install pyyaml", file=sys.stderr) sys.exit(1) with open(config_path) as f: config = yaml.safe_load(f) desired = config.get("records", []) current = {r["name"]: r for r in list_records(zone_id, token)} added = 0 updated = 0 unchanged = 0 for rec in desired: name = rec["name"] ip = rec["ip"] ttl = rec.get("ttl", 300) if name in current: if current[name]["content"] == ip: unchanged += 1 else: update_record(zone_id, token, name, ip, ttl) updated += 1 else: add_record(zone_id, token, name, ip, ttl) added += 1 print(f"\nSync complete: {added} added, {updated} updated, {unchanged} unchanged") # ── CLI ────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="Manage DNS records via Cloudflare API") sub = parser.add_subparsers(dest="command") # list p_list = sub.add_parser("list", help="List DNS records") p_list.add_argument("--zone", required=True, help="Zone name (e.g., example.com)") p_list.add_argument("--name", default="", help="Filter by record name") # add p_add = sub.add_parser("add", help="Add DNS A record") p_add.add_argument("--zone", required=True) p_add.add_argument("--name", required=True, help="Record name (e.g., forge.example.com)") p_add.add_argument("--ip", required=True, help="IPv4 address") p_add.add_argument("--ttl", type=int, default=300) # update p_update = sub.add_parser("update", help="Update DNS A record") p_update.add_argument("--zone", required=True) p_update.add_argument("--name", required=True) p_update.add_argument("--ip", required=True) p_update.add_argument("--ttl", type=int, default=300) # delete p_delete = sub.add_parser("delete", help="Delete DNS A record") p_delete.add_argument("--zone", required=True) p_delete.add_argument("--name", required=True) # sync p_sync = sub.add_parser("sync", help="Sync records from YAML config") p_sync.add_argument("--zone", required=True) p_sync.add_argument("--config", required=True, help="Path to YAML config") args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) token = get_token() zone_id = resolve_zone_id(args.zone, token) if args.command == "list": records = list_records(zone_id, token, args.name) for r in sorted(records, key=lambda x: x["name"]): print(f" {r['type']:5s} {r['name']:40s} -> {r['content']:20s} TTL:{r['ttl']}") print(f"\n{len(records)} records") elif args.command == "add": add_record(zone_id, token, args.name, args.ip, args.ttl) elif args.command == "update": update_record(zone_id, token, args.name, args.ip, args.ttl) elif args.command == "delete": delete_record(zone_id, token, args.name) elif args.command == "sync": sync_records(zone_id, token, args.config) if __name__ == "__main__": main()