#!/usr/bin/env python3 """ Alibaba Cloud DNS Batch Provisioner Reads a JSON plan file and creates/verifies all DNS records in batch. Usage: python alidns_batch.py --domain gogenex.com --plan plan.json [--dry-run] [--force] python alidns_batch.py --domain gogenex.com --export current_records.json python alidns_batch.py --domain gogenex.com --diff plan.json Plan JSON format (see references/plan-schema.md for full spec): [ {"rr": "api", "type": "A", "value": "1.2.3.4", "ttl": 600, "scope": "public", "note": "API gateway"}, {"rr": "www", "type": "CNAME", "value": "gogenex.com", "ttl": 600, "scope": "public", "note": "WWW redirect"}, {"rr": "mpc", "type": "A", "value": "10.0.1.100", "ttl": 600, "scope": "private", "note": "MPC signing service"}, {"rr": "static", "type": "CNAME", "value": "cdn.example.com", "ttl": 300, "scope": "public", "note": "CDN for static assets"} ] Environment Variables: ALIBABA_CLOUD_ACCESS_KEY_ID - RAM sub-user Access Key ID ALIBABA_CLOUD_ACCESS_KEY_SECRET - RAM sub-user Access Key Secret """ import argparse import ipaddress import json import os import sys import time try: from alibabacloud_alidns20150109.client import Client as AlidnsClient from alibabacloud_alidns20150109 import models as alidns_models from alibabacloud_tea_openapi import models as open_api_models except ImportError: print("ERROR: Required packages not installed. Run:") print(" pip install alibabacloud_alidns20150109 alibabacloud_tea_openapi --break-system-packages") sys.exit(1) # Private IP ranges (RFC 1918 + RFC 6598) PRIVATE_RANGES = [ ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("100.64.0.0/10"), ] def is_private_ip(ip_str: str) -> bool: """Check if an IP address is in a private range.""" try: ip = ipaddress.ip_address(ip_str) return any(ip in net for net in PRIVATE_RANGES) except ValueError: return False def create_client(region_id: str = "cn-hangzhou") -> AlidnsClient: access_key_id = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID") access_key_secret = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET") if not access_key_id or not access_key_secret: print("ERROR: Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET env vars.") sys.exit(1) config = open_api_models.Config( access_key_id=access_key_id, access_key_secret=access_key_secret, ) config.endpoint = f"alidns.{region_id}.aliyuncs.com" return AlidnsClient(config) def get_existing_records(client: AlidnsClient, domain: str) -> list: """Fetch all existing DNS records for a domain.""" all_records = [] page = 1 page_size = 100 while True: request = alidns_models.DescribeDomainRecordsRequest( domain_name=domain, page_number=page, page_size=page_size, ) response = client.describe_domain_records(request) records = response.body.domain_records.record all_records.extend(records) if len(all_records) >= response.body.total_count: break page += 1 return all_records def find_matching_record(existing: list, rr: str, record_type: str) -> object: """Find an existing record matching the given RR and type.""" for r in existing: if r.rr == rr and r.type == record_type: return r return None def validate_plan(plan: list, domain: str) -> list: """Validate the plan and return warnings.""" warnings = [] seen = set() for i, entry in enumerate(plan): # Required fields for field in ["rr", "type", "value"]: if field not in entry: warnings.append(f"āŒ Entry {i}: missing required field '{field}'") # Duplicate check key = (entry.get("rr", ""), entry.get("type", "")) if key in seen: warnings.append(f"āš ļø Entry {i}: duplicate record {key[0]}.{domain} {key[1]}") seen.add(key) # Security: private scope pointing to public IP scope = entry.get("scope", "public") value = entry.get("value", "") record_type = entry.get("type", "") if scope == "private" and record_type == "A" and not is_private_ip(value): warnings.append( f"šŸ”’ SECURITY: Entry {i} ({entry.get('rr')}.{domain}) is marked 'private' " f"but points to public IP {value}. Internal services should use private IPs." ) if scope == "public" and record_type == "A" and is_private_ip(value): warnings.append( f"āš ļø Entry {i} ({entry.get('rr')}.{domain}) is marked 'public' " f"but points to private IP {value}. This won't be reachable from the internet." ) return warnings def apply_plan(client: AlidnsClient, domain: str, plan: list, dry_run: bool = False, force: bool = False): """Apply the DNS plan to Alibaba Cloud DNS.""" print(f"\n{'šŸ” DRY RUN' if dry_run else 'šŸš€ APPLYING'} plan for {domain}") print(f" Records in plan: {len(plan)}\n") # Validate warnings = validate_plan(plan, domain) if warnings: print("āš ļø Validation warnings:") for w in warnings: print(f" {w}") print() has_errors = any(w.startswith("āŒ") for w in warnings) has_security = any(w.startswith("šŸ”’") for w in warnings) if has_errors: print("āŒ Plan has errors. Fix them before applying.") return if has_security and not force: print("šŸ”’ Security warnings detected. Use --force to override.") return # Fetch existing records print("šŸ“” Fetching existing records...") existing = get_existing_records(client, domain) print(f" Found {len(existing)} existing records\n") stats = {"added": 0, "updated": 0, "skipped": 0, "errors": 0} for entry in plan: rr = entry["rr"] record_type = entry["type"] value = entry["value"] ttl = entry.get("ttl", 600) note = entry.get("note", "") fqdn = f"{rr}.{domain}" if rr != "@" else domain match = find_matching_record(existing, rr, record_type) if match: if match.value == value and match.ttl == ttl: print(f" ā­ļø SKIP {fqdn:<35} {record_type:<6} {value} (already correct)") stats["skipped"] += 1 else: print(f" āœļø UPDATE {fqdn:<35} {record_type:<6} {match.value} -> {value} {f'[{note}]' if note else ''}") if not dry_run: try: request = alidns_models.UpdateDomainRecordRequest( record_id=match.record_id, rr=rr, type=record_type, value=value, ttl=ttl, ) client.update_domain_record(request) stats["updated"] += 1 time.sleep(0.1) # Rate limit throttle except Exception as e: print(f" āŒ Error: {e}") stats["errors"] += 1 else: stats["updated"] += 1 else: print(f" āž• ADD {fqdn:<35} {record_type:<6} {value} {f'[{note}]' if note else ''}") if not dry_run: try: request = alidns_models.AddDomainRecordRequest( domain_name=domain, rr=rr, type=record_type, value=value, ttl=ttl, ) client.add_domain_record(request) stats["added"] += 1 time.sleep(0.1) except Exception as e: error_msg = str(e) if "DomainRecordDuplicate" in error_msg: print(f" āš ļø Already exists (different query match)") stats["skipped"] += 1 else: print(f" āŒ Error: {e}") stats["errors"] += 1 else: stats["added"] += 1 # Summary print(f"\n{'šŸ“Š DRY RUN SUMMARY' if dry_run else 'šŸ“Š EXECUTION SUMMARY'}") print(f" Added: {stats['added']}") print(f" Updated: {stats['updated']}") print(f" Skipped: {stats['skipped']}") print(f" Errors: {stats['errors']}") if dry_run: print(f"\nšŸ’” Run without --dry-run to apply these changes.") def export_records(client: AlidnsClient, domain: str, output_file: str): """Export current DNS records to a JSON plan file.""" records = get_existing_records(client, domain) plan = [] for r in records: plan.append({ "rr": r.rr, "type": r.type, "value": r.value, "ttl": r.ttl, "scope": "private" if (r.type == "A" and is_private_ip(r.value)) else "public", "note": "", "record_id": r.record_id, "status": r.status, }) with open(output_file, "w", encoding="utf-8") as f: json.dump(plan, f, indent=2, ensure_ascii=False) print(f"āœ… Exported {len(plan)} records to {output_file}") def diff_plan(client: AlidnsClient, domain: str, plan_file: str): """Show differences between current state and planned state.""" with open(plan_file, "r", encoding="utf-8") as f: plan = json.load(f) existing = get_existing_records(client, domain) print(f"\nšŸ” Diff: {plan_file} vs live records for {domain}\n") # Check plan entries against existing for entry in plan: rr = entry["rr"] record_type = entry["type"] value = entry["value"] ttl = entry.get("ttl", 600) fqdn = f"{rr}.{domain}" if rr != "@" else domain match = find_matching_record(existing, rr, record_type) if not match: print(f" āž• NEW {fqdn:<35} {record_type:<6} {value}") elif match.value != value: print(f" āœļø CHANGE {fqdn:<35} {record_type:<6} {match.value} -> {value}") elif match.ttl != ttl: print(f" ā±ļø TTL {fqdn:<35} {record_type:<6} TTL {match.ttl} -> {ttl}") else: print(f" āœ… OK {fqdn:<35} {record_type:<6} {value}") # Check for records not in plan plan_keys = {(e["rr"], e["type"]) for e in plan} for r in existing: if (r.rr, r.type) not in plan_keys: fqdn = f"{r.rr}.{domain}" if r.rr != "@" else domain print(f" āš ļø EXTRA {fqdn:<35} {r.type:<6} {r.value} (not in plan)") def main(): parser = argparse.ArgumentParser(description="Alibaba Cloud DNS Batch Provisioner") parser.add_argument("--domain", required=True, help="Domain name (e.g., gogenex.com)") parser.add_argument("--region", default="cn-hangzhou", help="Alidns region") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--plan", help="JSON plan file to apply") group.add_argument("--export", help="Export current records to JSON file") group.add_argument("--diff", help="Show diff between plan and current state") parser.add_argument("--dry-run", action="store_true", help="Preview changes without applying") parser.add_argument("--force", action="store_true", help="Override security warnings") args = parser.parse_args() client = create_client(args.region) if args.plan: with open(args.plan, "r", encoding="utf-8") as f: plan = json.load(f) apply_plan(client, args.domain, plan, dry_run=args.dry_run, force=args.force) elif args.export: export_records(client, args.domain, args.export) elif args.diff: diff_plan(client, args.domain, args.diff) if __name__ == "__main__": main()