#!/usr/bin/env python3 """Sovereign DNS management for fleet domains. Supports: - Cloudflare via REST API token - Route53 via boto3-compatible client (or injected client in tests) - add / update / delete A records - sync mode using an Ansible-style domain -> IP mapping YAML """ from __future__ import annotations import argparse import json import os import urllib.parse import urllib.request from pathlib import Path from typing import Callable import yaml DEFAULT_MAPPING_PATH = Path('configs/dns_records.example.yaml') def load_domain_mapping(path: str | Path) -> dict: data = yaml.safe_load(Path(path).read_text()) or {} if not isinstance(data, dict): raise ValueError('mapping file must contain a YAML object') data.setdefault('domain_ip_map', {}) if not isinstance(data['domain_ip_map'], dict): raise ValueError('domain_ip_map must be a mapping of domain -> IPv4') return data def detect_public_ip(urlopen_fn=urllib.request.urlopen, service_url: str = 'https://api.ipify.org') -> str: req = urllib.request.Request(service_url, headers={'User-Agent': 'sovereign-dns/1.0'}) with urlopen_fn(req, timeout=10) as resp: return resp.read().decode().strip() def resolve_domain_ip_map(domain_ip_map: dict[str, str], current_public_ip: str) -> dict[str, str]: resolved = {} for domain, value in domain_ip_map.items(): if isinstance(value, str) and value.strip().lower() in {'auto', '__public_ip__', '$public_ip'}: resolved[domain] = current_public_ip else: resolved[domain] = value return resolved def build_sync_plan(current: dict[str, dict], desired: dict[str, str]) -> dict[str, list[dict]]: create: list[dict] = [] update: list[dict] = [] delete: list[dict] = [] for name, ip in desired.items(): existing = current.get(name) if existing is None: create.append({'name': name, 'content': ip}) elif existing.get('content') != ip: update.append({'name': name, 'id': existing.get('id'), 'content': ip}) for name, record in current.items(): if name not in desired: delete.append({'name': name, 'id': record.get('id')}) return {'create': create, 'update': update, 'delete': delete} class CloudflareDNSProvider: def __init__(self, api_token: str, zone_id: str, request_fn: Callable | None = None): self.api_token = api_token self.zone_id = zone_id self.request_fn = request_fn or self._request def _request(self, method: str, path: str, payload: dict | None = None) -> dict: url = 'https://api.cloudflare.com/client/v4' + path data = None if payload is None else json.dumps(payload).encode() req = urllib.request.Request( url, data=data, method=method, headers={ 'Authorization': f'Bearer {self.api_token}', 'Content-Type': 'application/json', }, ) with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) def list_a_records(self) -> dict[str, dict]: path = f'/zones/{self.zone_id}/dns_records?type=A&per_page=500' data = self.request_fn('GET', path) return {item['name']: {'id': item['id'], 'content': item['content']} for item in data.get('result', [])} def upsert_a_record(self, name: str, content: str) -> dict: lookup_path = f'/zones/{self.zone_id}/dns_records?type=A&name={urllib.parse.quote(name)}' existing = self.request_fn('GET', lookup_path).get('result', []) payload = {'type': 'A', 'name': name, 'content': content, 'ttl': 120, 'proxied': False} if existing: return self.request_fn('PUT', f"/zones/{self.zone_id}/dns_records/{existing[0]['id']}", payload) return self.request_fn('POST', f'/zones/{self.zone_id}/dns_records', payload) def delete_record(self, record_id: str) -> dict: return self.request_fn('DELETE', f'/zones/{self.zone_id}/dns_records/{record_id}') def apply_plan(self, create: list[dict], update: list[dict], delete: list[dict], current: dict[str, dict] | None = None) -> dict: results = {'created': [], 'updated': [], 'deleted': []} for item in create: self.upsert_a_record(item['name'], item['content']) results['created'].append(item['name']) for item in update: self.upsert_a_record(item['name'], item['content']) results['updated'].append(item['name']) current = current or {} for item in delete: record_id = item.get('id') or current.get(item['name'], {}).get('id') if record_id: self.delete_record(record_id) results['deleted'].append(item['name']) return results class Route53DNSProvider: def __init__(self, hosted_zone_id: str, client=None): self.hosted_zone_id = hosted_zone_id if client is None: import boto3 # optional runtime dependency client = boto3.client('route53') self.client = client def list_a_records(self) -> dict[str, dict]: data = self.client.list_resource_record_sets(HostedZoneId=self.hosted_zone_id) result = {} for item in data.get('ResourceRecordSets', []): if item.get('Type') != 'A': continue name = item['Name'].rstrip('.') values = item.get('ResourceRecords', []) if values: result[name] = {'content': values[0]['Value']} return result def apply_plan(self, create: list[dict], update: list[dict], delete: list[dict], current: dict[str, dict] | None = None) -> dict: current = current or {} changes = [] for item in create: changes.append({ 'Action': 'CREATE', 'ResourceRecordSet': { 'Name': item['name'], 'Type': 'A', 'TTL': 120, 'ResourceRecords': [{'Value': item['content']}], }, }) for item in update: changes.append({ 'Action': 'UPSERT', 'ResourceRecordSet': { 'Name': item['name'], 'Type': 'A', 'TTL': 120, 'ResourceRecords': [{'Value': item['content']}], }, }) for item in delete: old = current.get(item['name'], {}) if old.get('content'): changes.append({ 'Action': 'DELETE', 'ResourceRecordSet': { 'Name': item['name'], 'Type': 'A', 'TTL': 120, 'ResourceRecords': [{'Value': old['content']}], }, }) if changes: self.client.change_resource_record_sets( HostedZoneId=self.hosted_zone_id, ChangeBatch={'Changes': changes, 'Comment': 'sovereign_dns sync'}, ) return {'changes': changes} def build_provider(provider_name: str, zone_id: str, api_token: str | None = None): provider_name = provider_name.lower() if provider_name == 'cloudflare': if not api_token: raise ValueError('Cloudflare requires api_token') return CloudflareDNSProvider(api_token=api_token, zone_id=zone_id) if provider_name == 'route53': return Route53DNSProvider(hosted_zone_id=zone_id) raise ValueError(f'Unsupported provider: {provider_name}') def main() -> int: parser = argparse.ArgumentParser(description='Manage sovereign DNS A records via provider APIs') sub = parser.add_subparsers(dest='command', required=True) sync_p = sub.add_parser('sync', help='Sync desired domain->IP mapping to provider') sync_p.add_argument('--mapping', default=str(DEFAULT_MAPPING_PATH)) sync_p.add_argument('--provider') sync_p.add_argument('--zone-id') sync_p.add_argument('--api-token-env', default='CLOUDFLARE_API_TOKEN') sync_p.add_argument('--public-ip-url', default='https://api.ipify.org') upsert_p = sub.add_parser('upsert', help='Create or update a single A record') upsert_p.add_argument('--provider', required=True) upsert_p.add_argument('--zone-id', required=True) upsert_p.add_argument('--name', required=True) upsert_p.add_argument('--content', required=True) upsert_p.add_argument('--api-token-env', default='CLOUDFLARE_API_TOKEN') delete_p = sub.add_parser('delete', help='Delete a single A record') delete_p.add_argument('--provider', required=True) delete_p.add_argument('--zone-id', required=True) delete_p.add_argument('--name', required=True) delete_p.add_argument('--api-token-env', default='CLOUDFLARE_API_TOKEN') args = parser.parse_args() if args.command == 'sync': cfg = load_domain_mapping(args.mapping) provider_name = args.provider or cfg.get('dns_provider', 'cloudflare') zone_id = args.zone_id or cfg.get('dns_zone_id') or cfg.get('hosted_zone_id') token = os.environ.get(args.api_token_env, '') provider = build_provider(provider_name, zone_id=zone_id, api_token=token) current = provider.list_a_records() public_ip = detect_public_ip(service_url=args.public_ip_url) desired = resolve_domain_ip_map(cfg['domain_ip_map'], current_public_ip=public_ip) plan = build_sync_plan(current=current, desired=desired) result = provider.apply_plan(**plan, current=current) print(json.dumps({'provider': provider_name, 'zone_id': zone_id, 'public_ip': public_ip, 'plan': plan, 'result': result}, indent=2)) return 0 if args.command == 'upsert': token = os.environ.get(args.api_token_env, '') provider = build_provider(args.provider, zone_id=args.zone_id, api_token=token) result = provider.upsert_a_record(args.name, args.content) print(json.dumps(result, indent=2)) return 0 if args.command == 'delete': token = os.environ.get(args.api_token_env, '') provider = build_provider(args.provider, zone_id=args.zone_id, api_token=token) current = provider.list_a_records() record = current.get(args.name) if not record: raise SystemExit(f'No A record found for {args.name}') if isinstance(provider, CloudflareDNSProvider): result = provider.delete_record(record['id']) else: result = provider.apply_plan(create=[], update=[], delete=[{'name': args.name}], current=current) print(json.dumps(result, indent=2)) return 0 raise SystemExit('Unknown command') if __name__ == '__main__': raise SystemExit(main())