mirror of
https://github.com/blackboxprogramming/BlackRoad-Operating-System.git
synced 2026-03-17 04:57:15 -05:00
485 lines
15 KiB
Python
Executable File
485 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Cloudflare DNS Sync Script
|
|
===========================
|
|
|
|
Syncs DNS records from records.yaml to Cloudflare via API.
|
|
This script is idempotent - safe to run multiple times.
|
|
|
|
How to run:
|
|
-----------
|
|
1. Get your Cloudflare API token:
|
|
- Go to Cloudflare dashboard → My Profile → API Tokens
|
|
- Create token with "Zone.DNS" edit permissions
|
|
- Copy the token
|
|
|
|
2. Get your zone IDs:
|
|
- Go to each domain in Cloudflare dashboard
|
|
- Copy the Zone ID from the Overview page (right sidebar)
|
|
- Update records.yaml with the zone IDs
|
|
|
|
3. Set environment variables:
|
|
export CF_API_TOKEN="your-cloudflare-api-token"
|
|
|
|
4. Run the script:
|
|
python infra/cloudflare/cloudflare_dns_sync.py
|
|
|
|
Optional flags:
|
|
--------------
|
|
--dry-run Show what would change without making changes
|
|
--domain NAME Only sync specific domain (e.g., blackroad.systems)
|
|
--phase N Only sync domains in specific phase (1, 2, etc.)
|
|
--delete-extra Delete DNS records not in records.yaml (use carefully!)
|
|
|
|
Examples:
|
|
---------
|
|
# Dry run (safe - shows changes without applying)
|
|
python infra/cloudflare/cloudflare_dns_sync.py --dry-run
|
|
|
|
# Sync only blackroad.systems
|
|
python infra/cloudflare/cloudflare_dns_sync.py --domain blackroad.systems
|
|
|
|
# Sync only Phase 1 domains
|
|
python infra/cloudflare/cloudflare_dns_sync.py --phase 1
|
|
|
|
# Sync and delete extra records (DANGEROUS - be careful!)
|
|
python infra/cloudflare/cloudflare_dns_sync.py --delete-extra
|
|
|
|
Requirements:
|
|
------------
|
|
pip install pyyaml requests
|
|
|
|
Author: BlackRoad OS Infrastructure Team
|
|
Version: 1.0
|
|
Date: 2025-11-18
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import argparse
|
|
import logging
|
|
from typing import Dict, List, Tuple
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print("Error: pyyaml is not installed. Run: pip install pyyaml")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
print("Error: requests is not installed. Run: pip install requests")
|
|
sys.exit(1)
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cloudflare API configuration
|
|
CF_API_BASE = "https://api.cloudflare.com/client/v4"
|
|
CF_API_TOKEN = os.getenv("CF_API_TOKEN")
|
|
|
|
|
|
class CloudflareAPI:
|
|
"""Simple wrapper for Cloudflare API calls."""
|
|
|
|
def __init__(self, api_token: str):
|
|
if not api_token:
|
|
raise ValueError("CF_API_TOKEN environment variable not set")
|
|
|
|
self.api_token = api_token
|
|
self.session = requests.Session()
|
|
self.session.headers.update({
|
|
"Authorization": f"Bearer {api_token}",
|
|
"Content-Type": "application/json"
|
|
})
|
|
|
|
def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
|
|
"""Make a request to Cloudflare API."""
|
|
url = f"{CF_API_BASE}{endpoint}"
|
|
response = self.session.request(method, url, **kwargs)
|
|
|
|
# Parse JSON response
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
logger.error(f"Invalid JSON response from {url}")
|
|
response.raise_for_status()
|
|
return {}
|
|
|
|
# Check for API errors
|
|
if not data.get("success", False):
|
|
errors = data.get("errors", [])
|
|
error_msg = ", ".join([e.get("message", "Unknown error") for e in errors])
|
|
logger.error(f"Cloudflare API error: {error_msg}")
|
|
raise Exception(f"Cloudflare API error: {error_msg}")
|
|
|
|
return data.get("result", {})
|
|
|
|
def get_dns_records(self, zone_id: str) -> List[Dict]:
|
|
"""Get all DNS records for a zone."""
|
|
logger.info(f"Fetching DNS records for zone {zone_id}")
|
|
|
|
records = []
|
|
page = 1
|
|
per_page = 100
|
|
|
|
while True:
|
|
result = self._request(
|
|
"GET",
|
|
f"/zones/{zone_id}/dns_records",
|
|
params={"page": page, "per_page": per_page}
|
|
)
|
|
|
|
if isinstance(result, list):
|
|
records.extend(result)
|
|
if len(result) < per_page:
|
|
break
|
|
page += 1
|
|
elif isinstance(result, dict):
|
|
# Newer API returns paginated result
|
|
records.extend(result.get("result", []))
|
|
info = result.get("result_info", {})
|
|
if info.get("page", 1) >= info.get("total_pages", 1):
|
|
break
|
|
page += 1
|
|
else:
|
|
break
|
|
|
|
logger.info(f"Found {len(records)} existing DNS records")
|
|
return records
|
|
|
|
def create_dns_record(self, zone_id: str, record: Dict) -> Dict:
|
|
"""Create a new DNS record."""
|
|
logger.info(f"Creating {record['type']} record: {record['name']} → {record['content']}")
|
|
return self._request("POST", f"/zones/{zone_id}/dns_records", json=record)
|
|
|
|
def update_dns_record(self, zone_id: str, record_id: str, record: Dict) -> Dict:
|
|
"""Update an existing DNS record."""
|
|
logger.info(f"Updating {record['type']} record: {record['name']} → {record['content']}")
|
|
return self._request("PUT", f"/zones/{zone_id}/dns_records/{record_id}", json=record)
|
|
|
|
def delete_dns_record(self, zone_id: str, record_id: str) -> Dict:
|
|
"""Delete a DNS record."""
|
|
logger.warning(f"Deleting DNS record: {record_id}")
|
|
return self._request("DELETE", f"/zones/{zone_id}/dns_records/{record_id}")
|
|
|
|
|
|
def load_records_config() -> List[Dict]:
|
|
"""Load DNS records from records.yaml."""
|
|
config_path = Path(__file__).parent / "records.yaml"
|
|
|
|
if not config_path.exists():
|
|
logger.error(f"Config file not found: {config_path}")
|
|
sys.exit(1)
|
|
|
|
logger.info(f"Loading DNS configuration from {config_path}")
|
|
|
|
with open(config_path, 'r') as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
if not isinstance(data, list):
|
|
logger.error("Invalid records.yaml format - expected list of domains")
|
|
sys.exit(1)
|
|
|
|
return data
|
|
|
|
|
|
def normalize_record_name(name: str, domain: str) -> str:
|
|
"""Normalize record name for comparison."""
|
|
if name == '@':
|
|
return domain
|
|
elif name.endswith(f'.{domain}'):
|
|
return name
|
|
else:
|
|
return f"{name}.{domain}"
|
|
|
|
|
|
def records_equal(r1: Dict, r2: Dict) -> bool:
|
|
"""Check if two DNS records are functionally equal."""
|
|
# Compare essential fields
|
|
if r1.get('type') != r2.get('type'):
|
|
return False
|
|
if r1.get('content') != r2.get('content'):
|
|
return False
|
|
if r1.get('proxied', False) != r2.get('proxied', False):
|
|
return False
|
|
|
|
# For MX records, also compare priority
|
|
if r1.get('type') == 'MX':
|
|
if r1.get('priority') != r2.get('priority'):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def build_cloudflare_record(record: Dict, domain: str) -> Dict:
|
|
"""Build a Cloudflare API record payload from our config format."""
|
|
cf_record = {
|
|
'type': record['type'],
|
|
'name': normalize_record_name(record['name'], domain),
|
|
'content': record['content'],
|
|
'ttl': record.get('ttl', 1),
|
|
}
|
|
|
|
# Add proxied flag (not for MX, TXT, some others)
|
|
if record['type'] in ['A', 'AAAA', 'CNAME']:
|
|
cf_record['proxied'] = record.get('proxied', False)
|
|
|
|
# Add priority for MX records
|
|
if record['type'] == 'MX':
|
|
cf_record['priority'] = record.get('priority', 10)
|
|
|
|
# Add comment if supported (newer Cloudflare API)
|
|
if 'comment' in record:
|
|
cf_record['comment'] = record['comment']
|
|
|
|
return cf_record
|
|
|
|
|
|
def sync_domain(
|
|
api: CloudflareAPI,
|
|
domain_config: Dict,
|
|
dry_run: bool = False,
|
|
delete_extra: bool = False
|
|
) -> Tuple[int, int, int, int]:
|
|
"""
|
|
Sync DNS records for a single domain.
|
|
Returns: (created, updated, deleted, unchanged) counts
|
|
"""
|
|
domain = domain_config['domain']
|
|
zone_id = domain_config.get('zone_id', '')
|
|
desired_records = domain_config.get('records', [])
|
|
|
|
logger.info(f"\n{'='*60}")
|
|
logger.info(f"Syncing domain: {domain}")
|
|
logger.info(f"Zone ID: {zone_id}")
|
|
logger.info(f"Desired records: {len(desired_records)}")
|
|
logger.info(f"{'='*60}")
|
|
|
|
if not zone_id or 'REPLACE' in zone_id:
|
|
logger.error(f"Skipping {domain} - Zone ID not configured in records.yaml")
|
|
return (0, 0, 0, 0)
|
|
|
|
# Get existing records from Cloudflare
|
|
try:
|
|
existing_records = api.get_dns_records(zone_id)
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch DNS records for {domain}: {e}")
|
|
return (0, 0, 0, 0)
|
|
|
|
# Build index of existing records
|
|
existing_index = {}
|
|
for record in existing_records:
|
|
key = f"{record['type']}:{record['name']}"
|
|
existing_index[key] = record
|
|
|
|
# Track changes
|
|
created = 0
|
|
updated = 0
|
|
deleted = 0
|
|
unchanged = 0
|
|
|
|
# Process desired records
|
|
for desired_record in desired_records:
|
|
cf_record = build_cloudflare_record(desired_record, domain)
|
|
record_type = cf_record['type']
|
|
record_name = cf_record['name']
|
|
key = f"{record_type}:{record_name}"
|
|
|
|
if key in existing_index:
|
|
# Record exists - check if update needed
|
|
existing = existing_index[key]
|
|
|
|
if records_equal(cf_record, existing):
|
|
logger.debug(f"✓ Unchanged: {key}")
|
|
unchanged += 1
|
|
else:
|
|
logger.info(f"↻ Update needed: {key}")
|
|
logger.info(f" Current: {existing.get('content')}")
|
|
logger.info(f" Desired: {cf_record.get('content')}")
|
|
|
|
if not dry_run:
|
|
try:
|
|
api.update_dns_record(zone_id, existing['id'], cf_record)
|
|
updated += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to update {key}: {e}")
|
|
else:
|
|
logger.info(f" [DRY RUN] Would update record")
|
|
updated += 1
|
|
|
|
# Mark as processed
|
|
del existing_index[key]
|
|
else:
|
|
# Record doesn't exist - create it
|
|
logger.info(f"+ Create needed: {key}")
|
|
|
|
if not dry_run:
|
|
try:
|
|
api.create_dns_record(zone_id, cf_record)
|
|
created += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to create {key}: {e}")
|
|
else:
|
|
logger.info(f" [DRY RUN] Would create record")
|
|
created += 1
|
|
|
|
# Handle extra records not in config
|
|
if existing_index:
|
|
logger.info(f"\nFound {len(existing_index)} extra records not in config:")
|
|
for key, record in existing_index.items():
|
|
logger.info(f" - {key} → {record.get('content')}")
|
|
|
|
if delete_extra:
|
|
logger.warning("Deleting extra records (--delete-extra flag set)")
|
|
for key, record in existing_index.items():
|
|
if not dry_run:
|
|
try:
|
|
api.delete_dns_record(zone_id, record['id'])
|
|
deleted += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete {key}: {e}")
|
|
else:
|
|
logger.warning(f" [DRY RUN] Would delete {key}")
|
|
deleted += 1
|
|
else:
|
|
logger.info(" (Use --delete-extra to remove these records)")
|
|
|
|
# Summary
|
|
logger.info(f"\nSummary for {domain}:")
|
|
logger.info(f" Created: {created}")
|
|
logger.info(f" Updated: {updated}")
|
|
logger.info(f" Deleted: {deleted}")
|
|
logger.info(f" Unchanged: {unchanged}")
|
|
|
|
return (created, updated, deleted, unchanged)
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Sync DNS records from records.yaml to Cloudflare",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Show what would change without making changes"
|
|
)
|
|
parser.add_argument(
|
|
"--domain",
|
|
type=str,
|
|
help="Only sync specific domain (e.g., blackroad.systems)"
|
|
)
|
|
parser.add_argument(
|
|
"--phase",
|
|
type=int,
|
|
choices=[1, 2, 3],
|
|
help="Only sync domains in specific phase"
|
|
)
|
|
parser.add_argument(
|
|
"--delete-extra",
|
|
action="store_true",
|
|
help="Delete DNS records not in records.yaml (use carefully!)"
|
|
)
|
|
parser.add_argument(
|
|
"--verbose",
|
|
action="store_true",
|
|
help="Enable verbose logging"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.verbose:
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
if args.dry_run:
|
|
logger.info("🔍 DRY RUN MODE - No changes will be made\n")
|
|
|
|
# Load configuration
|
|
try:
|
|
domains = load_records_config()
|
|
except Exception as e:
|
|
logger.error(f"Failed to load configuration: {e}")
|
|
sys.exit(1)
|
|
|
|
# Initialize API client
|
|
try:
|
|
api = CloudflareAPI(CF_API_TOKEN)
|
|
except ValueError as e:
|
|
logger.error(str(e))
|
|
logger.error("\nTo get your Cloudflare API token:")
|
|
logger.error("1. Go to Cloudflare dashboard → My Profile → API Tokens")
|
|
logger.error("2. Create token with 'Zone.DNS' edit permissions")
|
|
logger.error("3. Set environment variable: export CF_API_TOKEN='your-token'")
|
|
sys.exit(1)
|
|
|
|
# Filter domains
|
|
filtered_domains = []
|
|
for domain_config in domains:
|
|
# Filter by specific domain
|
|
if args.domain and domain_config['domain'] != args.domain:
|
|
continue
|
|
|
|
# Filter by phase
|
|
if args.phase and domain_config.get('phase') != args.phase:
|
|
continue
|
|
|
|
filtered_domains.append(domain_config)
|
|
|
|
if not filtered_domains:
|
|
logger.warning("No domains matched your filters")
|
|
sys.exit(0)
|
|
|
|
logger.info(f"Processing {len(filtered_domains)} domain(s)\n")
|
|
|
|
# Sync each domain
|
|
total_created = 0
|
|
total_updated = 0
|
|
total_deleted = 0
|
|
total_unchanged = 0
|
|
|
|
for domain_config in filtered_domains:
|
|
created, updated, deleted, unchanged = sync_domain(
|
|
api,
|
|
domain_config,
|
|
dry_run=args.dry_run,
|
|
delete_extra=args.delete_extra
|
|
)
|
|
|
|
total_created += created
|
|
total_updated += updated
|
|
total_deleted += deleted
|
|
total_unchanged += unchanged
|
|
|
|
# Final summary
|
|
logger.info(f"\n{'='*60}")
|
|
logger.info("OVERALL SUMMARY")
|
|
logger.info(f"{'='*60}")
|
|
logger.info(f"Domains processed: {len(filtered_domains)}")
|
|
logger.info(f"Records created: {total_created}")
|
|
logger.info(f"Records updated: {total_updated}")
|
|
logger.info(f"Records deleted: {total_deleted}")
|
|
logger.info(f"Records unchanged: {total_unchanged}")
|
|
logger.info(f"{'='*60}")
|
|
|
|
if args.dry_run:
|
|
logger.info("\n🔍 This was a DRY RUN - no changes were made")
|
|
logger.info("Run without --dry-run to apply changes")
|
|
else:
|
|
logger.info("\n✅ DNS sync complete!")
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|