Files
timmy-home/scripts/dns-manager.py
Alexander Whitestone ce3da2dbc4
Some checks failed
Smoke Test / smoke (pull_request) Failing after 2m25s
feat: sovereign DNS management via Cloudflare API (#692)
DNS record management script with Cloudflare API integration:

Operations:
  python3 scripts/dns-manager.py list --zone example.com
  python3 scripts/dns-manager.py add --zone example.com --name forge --ip 1.2.3.4
  python3 scripts/dns-manager.py update --zone example.com --name forge --ip 5.6.7.8
  python3 scripts/dns-manager.py delete --zone example.com --name forge
  python3 scripts/dns-manager.py sync --zone example.com --config dns-records.yaml

Features:
- Cloudflare API v4 integration (stdlib only, no deps)
- Zone auto-resolution from domain name
- Sync from YAML config (add missing, update changed)
- API token from CLOUDFLARE_API_TOKEN env or ~/.config/cloudflare/token
- Fleet DNS records: forge, bezalel, allegro subdomains

Also: dns-records.yaml with current fleet domain mappings.
2026-04-15 23:47:32 -04:00

263 lines
9.3 KiB
Python
Executable File

#!/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()