241 lines
5.3 KiB
Python
241 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import json
|
|
import os
|
|
|
|
import requests
|
|
import yaml
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
|
|
TOKEN = os.getenv("CF_API_TOKEN")
|
|
ZONE = os.getenv("CF_ZONE_ID")
|
|
|
|
HEADERS = {
|
|
"Authorization": f"Bearer {TOKEN}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
def dns_key(record_type: str, name: str) -> str:
|
|
return f"{record_type}:{name}"
|
|
|
|
# ------------------------------------------------------------
|
|
# Load desired configuration
|
|
# ------------------------------------------------------------
|
|
|
|
with open("dns.yaml", "r") as f:
|
|
config = yaml.safe_load(f)
|
|
|
|
zone = config["zone"]
|
|
|
|
defaults = config.get("defaults", {})
|
|
aliases = config.get("aliases", {})
|
|
ignore = set(config.get("ignore", []))
|
|
|
|
default_type = defaults.get("type", "A")
|
|
default_content = defaults.get("content")
|
|
default_ttl = defaults.get("ttl", 1)
|
|
default_proxied = defaults.get("proxied", False)
|
|
|
|
desired = {}
|
|
|
|
for name, record in config.get("records", {}).items():
|
|
|
|
fqdn = zone if name == "@" else f"{name}.{zone}"
|
|
|
|
rtype = record.get("type", default_type)
|
|
# Only manage A records for now
|
|
if rtype != "A":
|
|
continue
|
|
key = dns_key(rtype, fqdn)
|
|
|
|
desired[key] = {
|
|
"name": key,
|
|
"type": rtype,
|
|
"content": record.get("content", default_content),
|
|
"ttl": record.get("ttl", default_ttl),
|
|
"proxied": record.get("proxied", default_proxied),
|
|
}
|
|
|
|
# ------------------------------------------------------------
|
|
# Download Cloudflare records
|
|
# ------------------------------------------------------------
|
|
|
|
url = f"https://api.cloudflare.com/client/v4/zones/{ZONE}/dns_records"
|
|
|
|
response = requests.get(url, headers=HEADERS)
|
|
response.raise_for_status()
|
|
|
|
current = {}
|
|
|
|
for r in response.json()["result"]:
|
|
|
|
if r["type"] != "A":
|
|
continue
|
|
|
|
key = f"{r['type']}:{r['name']}"
|
|
|
|
current[key] = {
|
|
"id": r["id"],
|
|
"type": r["type"],
|
|
"content": r["content"],
|
|
"ttl": r["ttl"],
|
|
"proxied": r.get("proxied", False),
|
|
}
|
|
|
|
# ------------------------------------------------------------
|
|
# Build execution plan
|
|
# ------------------------------------------------------------
|
|
|
|
plan = {
|
|
"create": [],
|
|
"update": [],
|
|
"rename": [],
|
|
"delete": [],
|
|
}
|
|
|
|
handled_current = set()
|
|
|
|
# ------------------------------------------------------------
|
|
# Creates / Updates / Renames
|
|
# ------------------------------------------------------------
|
|
|
|
for key, wanted in desired.items():
|
|
|
|
fqdn = wanted["name"]
|
|
short = fqdn.replace(f".{zone}", "")
|
|
|
|
#
|
|
# Existing record?
|
|
#
|
|
|
|
if key in current:
|
|
|
|
handled_current.add(key)
|
|
|
|
existing = current[key]
|
|
|
|
changed = False
|
|
|
|
for key in ("type", "content", "ttl", "proxied"):
|
|
if existing[key] != wanted[key]:
|
|
print(f"\nDifference: {fqdn}")
|
|
print(f" {key}")
|
|
print(f" Current : {existing[key]!r}")
|
|
print(f" Wanted : {wanted[key]!r}")
|
|
changed = True
|
|
|
|
if changed:
|
|
plan["update"].append({
|
|
"name": key,
|
|
"id": existing["id"],
|
|
"desired": wanted,
|
|
})
|
|
|
|
continue
|
|
|
|
#
|
|
# Alias?
|
|
#
|
|
|
|
old = None
|
|
|
|
for alias, target in aliases.items():
|
|
|
|
if target != short:
|
|
continue
|
|
|
|
alias_key = dns_key(wanted["type"], f"{alias}.{zone}")
|
|
|
|
if alias_key in current:
|
|
|
|
handled_current.add(alias_key)
|
|
|
|
plan["rename"].append({
|
|
"old": alias_fqdn,
|
|
"new": fqdn,
|
|
"id": current[alias_key]["id"],
|
|
"desired": wanted,
|
|
})
|
|
|
|
old = alias_key
|
|
break
|
|
|
|
if old:
|
|
continue
|
|
|
|
#
|
|
# Create
|
|
#
|
|
|
|
plan["create"].append({
|
|
"name": key,
|
|
"desired": wanted,
|
|
})
|
|
|
|
# ------------------------------------------------------------
|
|
# Deletes
|
|
# ------------------------------------------------------------
|
|
|
|
for key, existing in current.items():
|
|
|
|
_, fqdn = key.split(":", 1)
|
|
|
|
short = fqdn.replace(f".{zone}", "")
|
|
|
|
if short in ignore:
|
|
continue
|
|
|
|
if key in handled_current:
|
|
continue
|
|
|
|
if key in desired:
|
|
continue
|
|
|
|
plan["delete"].append({
|
|
"name": key,
|
|
"id": existing["id"],
|
|
})
|
|
|
|
# ------------------------------------------------------------
|
|
# Print
|
|
# ------------------------------------------------------------
|
|
|
|
def heading(title):
|
|
print()
|
|
print("=" * 70)
|
|
print(title)
|
|
print("=" * 70)
|
|
|
|
heading(f"CREATE ({len(plan['create'])})")
|
|
|
|
for item in sorted(plan["create"], key=lambda x: x["name"]):
|
|
print(item["name"])
|
|
|
|
heading(f"RENAME ({len(plan['rename'])})")
|
|
|
|
for item in sorted(plan["rename"], key=lambda x: x["old"]):
|
|
print(f"{item['old']} -> {item['new']}")
|
|
|
|
heading(f"UPDATE ({len(plan['update'])})")
|
|
|
|
for item in sorted(plan["update"], key=lambda x: x["name"]):
|
|
print(item["name"])
|
|
|
|
heading(f"DELETE ({len(plan['delete'])})")
|
|
|
|
for item in sorted(plan["delete"], key=lambda x: x["name"]):
|
|
print(item["name"])
|
|
|
|
# ------------------------------------------------------------
|
|
# Save plan
|
|
# ------------------------------------------------------------
|
|
|
|
with open("plan.json", "w") as f:
|
|
json.dump(plan, f, indent=4)
|
|
|
|
print()
|
|
print("Execution plan written to plan.json")
|