330 lines
12 KiB
Python
330 lines
12 KiB
Python
#!/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()
|