gcx/scripts/alidns_batch.py

330 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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