diff --git a/dns_sync.py b/dns_sync.py index 7211c5a..96afeaf 100644 --- a/dns_sync.py +++ b/dns_sync.py @@ -2,16 +2,24 @@ """ Hostinger DNS Sync — watches Docker/Traefik events and auto-manages DNS records using the official hostinger_api Python SDK. + +The Hostinger API returns records in this shape: + [{ "name": "sub", "type": "A", "ttl": 3600, "records": [{"content": "1.2.3.4"}] }] + +For the PUT update, each zone entry has the same shape with an "overwrite" flag. """ import os import re +import json import logging import requests import hostinger_api from hostinger_api.rest import ApiException -import docker +from collections import defaultdict +from pathlib import Path from typing import Optional +import docker logging.basicConfig( level=logging.INFO, @@ -22,34 +30,44 @@ log = logging.getLogger(__name__) # ── Config ───────────────────────────────────────────────────────────────────── HOSTINGER_API_KEY = os.environ["HOSTINGER_API_KEY"] -DOMAIN = os.environ["DOMAIN"] # e.g. example.com -PUBLIC_IP = os.environ.get("PUBLIC_IP", "") # auto-detected if empty +DOMAIN = os.environ["DOMAIN"] +PUBLIC_IP = os.environ.get("PUBLIC_IP", "") RECORD_TYPE = os.environ.get("RECORD_TYPE", "A") TTL = int(os.environ.get("TTL", "3600")) DELETE_ORPHANS = os.environ.get("DELETE_ORPHANS", "false").lower() == "true" DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true" -# ── SDK client setup ─────────────────────────────────────────────────────────── -configuration = hostinger_api.Configuration( - access_token=HOSTINGER_API_KEY -) +# Only subdomains that this tool has ever CREATED are eligible for deletion. +STATE_FILE = Path(os.environ.get("STATE_FILE", "/data/managed_records.json")) + +# ── SDK client ───────────────────────────────────────────────────────────────── +configuration = hostinger_api.Configuration(access_token=HOSTINGER_API_KEY) def dns_client() -> hostinger_api.DNSZoneApi: - client = hostinger_api.ApiClient(configuration) - return hostinger_api.DNSZoneApi(client) + return hostinger_api.DNSZoneApi(hostinger_api.ApiClient(configuration)) -# ── Public IP detection ──────────────────────────────────────────────────────── +# ── State persistence ────────────────────────────────────────────────────────── + +def load_managed() -> set[str]: + try: + return set(json.loads(STATE_FILE.read_text())) + except Exception: + return set() + + +def save_managed(names: set[str]): + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(sorted(names))) + + +# ── Public IP ────────────────────────────────────────────────────────────────── def get_public_ip() -> str: if PUBLIC_IP: return PUBLIC_IP - for url in [ - "https://api.ipify.org", - "https://checkip.amazonaws.com", - "https://ifconfig.me", - ]: + for url in ["https://api.ipify.org", "https://checkip.amazonaws.com", "https://ifconfig.me"]: try: ip = requests.get(url, timeout=5).text.strip() log.info(f"Auto-detected public IP: {ip}") @@ -59,15 +77,13 @@ def get_public_ip() -> str: raise RuntimeError("Could not auto-detect public IP. Set PUBLIC_IP env var.") -# ── Traefik label parsing ────────────────────────────────────────────────────── +# ── Docker / Traefik scanning ────────────────────────────────────────────────── def extract_hosts_from_rule(rule: str) -> list[str]: - """Parse Host(`foo.example.com`) from a Traefik router rule.""" return re.findall(r"Host\(`([^`]+)`\)", rule) def get_subdomain(host: str) -> Optional[str]: - """Return the subdomain name for this domain, or None if unrelated.""" if host == DOMAIN: return "@" if host.endswith(f".{DOMAIN}"): @@ -76,9 +92,13 @@ def get_subdomain(host: str) -> Optional[str]: def get_desired_subdomains(docker_client: docker.DockerClient) -> dict[str, str]: - """Scan running containers → return {subdomain_name: full_hostname}.""" + """ + Scan ALL containers (running + stopped) for Traefik router rules. + Returns {subdomain_name: full_hostname}. + Scanning stopped containers avoids deleting records for briefly-restarting services. + """ result = {} - for container in docker_client.containers.list(): + for container in docker_client.containers.list(all=True): labels = container.labels or {} for key, value in labels.items(): if re.match(r"traefik\.http\.routers\.[^.]+\.rule", key): @@ -91,95 +111,159 @@ def get_desired_subdomains(docker_client: docker.DockerClient) -> dict[str, str] return result -# ── DNS sync logic ───────────────────────────────────────────────────────────── +# ── Hostinger DNS ────────────────────────────────────────────────────────────── def fetch_current_records() -> list[dict]: - """Return all DNS records for the domain as plain dicts.""" - api = dns_client() + """ + Returns records as plain dicts. The SDK structure per record is: + { "name": "sub", "type": "A", "ttl": 3600, "records": [{"content": "1.2.3.4"}] } + """ try: - records = api.get_dns_records_v1(DOMAIN) - # SDK returns a list of DNSV1ZoneRecordResource objects - return [r.to_dict() for r in records] + raw = dns_client().get_dns_records_v1(DOMAIN) + result = [] + for r in raw: + d = r.to_dict() + # Log full structure on first record to help diagnose schema issues + if not result: + log.debug(f"SDK record sample: {d}") + result.append(d) + return result except ApiException as e: log.error(f"Failed to fetch DNS records: {e}") raise -def build_updated_zone( - current_records: list[dict], - desired: dict[str, str], - ip: str, -) -> list[dict]: +def get_record_ip(rec: dict) -> Optional[str]: """ - Merge current zone with desired state. - The Hostinger API uses a full-zone PUT — we must include ALL records we want - to keep, not just the ones we're changing. + Safely extract the IP from a record dict regardless of SDK nesting. + Handles both flat {"content": "..."} and nested {"records": [{"content": "..."}]}. """ - # Index existing target-type records by name - existing_by_name = { + # Nested form: {"records": [{"content": "1.2.3.4"}]} + if "records" in rec and isinstance(rec["records"], list) and rec["records"]: + first = rec["records"][0] + if isinstance(first, dict): + return first.get("content") + # SDK may return objects instead of dicts + if hasattr(first, "content"): + return first.content + # Flat form: {"content": "1.2.3.4"} + return rec.get("content") + + +def build_zone_entry(name: str, ip: str) -> hostinger_api.DNSV1ZoneUpdateRequestZoneInner: + return hostinger_api.DNSV1ZoneUpdateRequestZoneInner( + type=RECORD_TYPE, + name=name, + records=[ + hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner( + content=ip, + ttl=TTL, + ) + ], + ) + + +def rebuild_zone_entry_from_existing(rec: dict) -> hostinger_api.DNSV1ZoneUpdateRequestZoneInner: + """Re-encode an existing record (any type) back into an SDK update object.""" + # Extract the list of sub-records + sub_records = [] + raw_recs = rec.get("records", []) + if raw_recs: + for r in raw_recs: + if isinstance(r, dict): + sub_records.append( + hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner( + content=r.get("content", ""), + ttl=r.get("ttl", rec.get("ttl", TTL)), + ) + ) + elif hasattr(r, "content"): + sub_records.append( + hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner( + content=r.content, + ttl=getattr(r, "ttl", TTL), + ) + ) + else: + # Flat record (shouldn't happen with real SDK but be safe) + content = rec.get("content", "") + if content: + sub_records.append( + hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner( + content=content, + ttl=rec.get("ttl", TTL), + ) + ) + + return hostinger_api.DNSV1ZoneUpdateRequestZoneInner( + type=rec["type"], + name=rec["name"], + records=sub_records, + ) + + +def sync(docker_client: docker.DockerClient, ip: str): + desired = get_desired_subdomains(docker_client) + if not desired: + log.info("No Traefik hosts found for this domain — nothing to sync.") + return + + log.info(f"Desired subdomains: {sorted(desired.keys())}") + + managed = load_managed() + current_records = fetch_current_records() + + # Index existing records of our type by name + existing_ours = { r["name"]: r for r in current_records if r.get("type") == RECORD_TYPE } - - # Records of other types we must always preserve + # All other record types — always preserved verbatim other_records = [r for r in current_records if r.get("type") != RECORD_TYPE] - updated_a_records = [] + zone_entries = [] + new_managed = set(managed) - # Upsert desired records + # ── Upsert desired records ────────────────────────────────────────────── for name in desired: - if name in existing_by_name: - if existing_by_name[name].get("content") == ip: - log.debug(f"⏭ {name}.{DOMAIN} → {ip} (no change)") + if name in existing_ours: + current_ip = get_record_ip(existing_ours[name]) + if current_ip == ip: + log.debug(f"⏭ {name}.{DOMAIN} → {ip} (unchanged)") else: - log.info(f"✏️ UPDATE {RECORD_TYPE} {name}.{DOMAIN} → {ip}") + log.info(f"✏️ UPDATE {RECORD_TYPE} {name}.{DOMAIN}: {current_ip} → {ip}") else: log.info(f"➕ CREATE {RECORD_TYPE} {name}.{DOMAIN} → {ip}") - updated_a_records.append({"type": RECORD_TYPE, "name": name, "content": ip, "ttl": TTL}) + zone_entries.append(build_zone_entry(name, ip)) + new_managed.add(name) - # Handle records not in desired set - for name, rec in existing_by_name.items(): - if name not in desired: - if DELETE_ORPHANS: - log.info(f"🗑️ DELETE orphan {RECORD_TYPE} {name}.{DOMAIN}") - # Omitting it from the list removes it on PUT + # ── Handle existing records NOT in desired ────────────────────────────── + for name, rec in existing_ours.items(): + if name in desired: + continue + + if DELETE_ORPHANS and name in managed: + # Safe: this tool created it and it's no longer needed + log.info(f"🗑️ DELETE orphan {RECORD_TYPE} {name}.{DOMAIN}") + new_managed.discard(name) + # Omitted from zone_entries → removed by PUT + else: + if DELETE_ORPHANS and name not in managed: + log.warning( + f"⚠️ KEEPING {name}.{DOMAIN} — not in managed set " + f"(was created manually). " + f"To allow deletion, add it to {STATE_FILE}." + ) else: - log.debug(f"⏭ Keeping unmanaged record {name}.{DOMAIN}") - updated_a_records.append(rec) + log.debug(f"⏭ Keeping {name}.{DOMAIN}") + zone_entries.append(rebuild_zone_entry_from_existing(rec)) - return other_records + updated_a_records - - -def apply_zone(full_zone: list[dict]): - """Send the full updated zone to Hostinger via PUT (full-zone replacement).""" - api = dns_client() - - # Group records by (type, name) as the SDK expects - # DNSV1ZoneUpdateRequest.zone is a list of "name groups" - from collections import defaultdict - grouped: dict[tuple, list] = defaultdict(list) - for rec in full_zone: - key = (rec["type"], rec["name"]) - grouped[key].append(rec) - - zone_entries = [] - for (rtype, rname), recs in grouped.items(): - sdk_records = [ - hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner( - content=r["content"], - ttl=r.get("ttl", TTL), - ) - for r in recs - ] - zone_entries.append( - hostinger_api.DNSV1ZoneUpdateRequestZoneInner( - type=rtype, - name=rname, - records=sdk_records, - ) - ) + # ── Always re-include all other record types ──────────────────────────── + for rec in other_records: + zone_entries.append(rebuild_zone_entry_from_existing(rec)) + # ── Apply ─────────────────────────────────────────────────────────────── update_request = hostinger_api.DNSV1ZoneUpdateRequest(zone=zone_entries) if DRY_RUN: @@ -187,25 +271,15 @@ def apply_zone(full_zone: list[dict]): return try: - api.update_dns_records_v1(DOMAIN, update_request) + dns_client().update_dns_records_v1(DOMAIN, update_request) log.info(f"✅ Zone updated — {len(zone_entries)} record group(s)") except ApiException as e: - log.error(f"Failed to update DNS zone: {e}") - raise - - -def sync(docker_client: docker.DockerClient, ip: str): - desired = get_desired_subdomains(docker_client) - - if not desired: - log.info("No Traefik hosts found for this domain — nothing to sync.") + log.error(f"Failed to apply zone update: {e}") return - log.info(f"Desired subdomains: {list(desired.keys())}") - - current_records = fetch_current_records() - new_zone = build_updated_zone(current_records, desired, ip) - apply_zone(new_zone) + if new_managed != managed: + save_managed(new_managed) + log.debug(f"Managed state saved: {sorted(new_managed)}") # ── Docker event loop ────────────────────────────────────────────────────────── @@ -216,30 +290,34 @@ def watch_events(docker_client: docker.DockerClient, ip: str): status = event.get("status", "") if status in ("start", "die", "stop", "destroy"): name = event.get("Actor", {}).get("Attributes", {}).get("name", "?") - log.info(f"⚡ Event [{status}] on container '{name}' — syncing DNS...") + log.info(f"⚡ Event [{status}] on '{name}' — syncing DNS...") try: sync(docker_client, ip) except Exception as e: - log.error(f"Sync error: {e}") + log.error(f"Sync error: {e}", exc_info=True) # ── Entry point ──────────────────────────────────────────────────────────────── def main(): log.info( - f"🚀 Hostinger DNS Sync starting — " - f"domain={DOMAIN} ttl={TTL}s " + f"🚀 Hostinger DNS Sync — domain={DOMAIN} ttl={TTL}s " f"delete_orphans={DELETE_ORPHANS} dry_run={DRY_RUN}" ) + if DELETE_ORPHANS: + log.info( + f"🛡 Orphan deletion ON — only records in {STATE_FILE} " + f"(created by this tool) are ever deleted." + ) ip = get_public_ip() docker_client = docker.from_env() - log.info("🔄 Initial sync on startup...") + log.info("🔄 Initial sync...") try: sync(docker_client, ip) except Exception as e: - log.error(f"Initial sync failed: {e}") + log.error(f"Initial sync failed: {e}", exc_info=True) try: watch_events(docker_client, ip)