#!/usr/bin/env python3 """ Hostinger DNS Sync — watches Docker/Traefik events and auto-manages DNS records using the official hostinger_api Python SDK. """ import os import re import logging import requests import hostinger_api from hostinger_api.rest import ApiException import docker from typing import Optional logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) 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 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 ) def dns_client() -> hostinger_api.DNSZoneApi: client = hostinger_api.ApiClient(configuration) return hostinger_api.DNSZoneApi(client) # ── Public IP detection ──────────────────────────────────────────────────────── 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", ]: try: ip = requests.get(url, timeout=5).text.strip() log.info(f"Auto-detected public IP: {ip}") return ip except Exception: continue raise RuntimeError("Could not auto-detect public IP. Set PUBLIC_IP env var.") # ── Traefik label parsing ────────────────────────────────────────────────────── 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}"): return host[: -(len(DOMAIN) + 1)] return None def get_desired_subdomains(docker_client: docker.DockerClient) -> dict[str, str]: """Scan running containers → return {subdomain_name: full_hostname}.""" result = {} for container in docker_client.containers.list(): labels = container.labels or {} for key, value in labels.items(): if re.match(r"traefik\.http\.routers\.[^.]+\.rule", key): for host in extract_hosts_from_rule(value): sub = get_subdomain(host) if sub: result[sub] = host else: log.debug(f"Skipping {host} — not under {DOMAIN}") return result # ── DNS sync logic ───────────────────────────────────────────────────────────── def fetch_current_records() -> list[dict]: """Return all DNS records for the domain as plain dicts.""" api = dns_client() try: records = api.get_dns_records_v1(DOMAIN) # SDK returns a list of DNSV1ZoneRecordResource objects return [r.to_dict() for r in records] 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]: """ 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. """ # Index existing target-type records by name existing_by_name = { r["name"]: r for r in current_records if r.get("type") == RECORD_TYPE } # Records of other types we must always preserve other_records = [r for r in current_records if r.get("type") != RECORD_TYPE] updated_a_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)") else: log.info(f"✏️ UPDATE {RECORD_TYPE} {name}.{DOMAIN} → {ip}") else: log.info(f"➕ CREATE {RECORD_TYPE} {name}.{DOMAIN} → {ip}") updated_a_records.append({"type": RECORD_TYPE, "name": name, "content": ip, "ttl": TTL}) # 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 else: log.debug(f"⏭ Keeping unmanaged record {name}.{DOMAIN}") updated_a_records.append(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, ) ) update_request = hostinger_api.DNSV1ZoneUpdateRequest(zone=zone_entries) if DRY_RUN: log.info(f"[DRY RUN] Would PUT {len(zone_entries)} record group(s) to {DOMAIN}") return try: api.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.") 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) # ── Docker event loop ────────────────────────────────────────────────────────── def watch_events(docker_client: docker.DockerClient, ip: str): log.info("👂 Listening for Docker events...") for event in docker_client.events(decode=True, filters={"type": "container"}): 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...") try: sync(docker_client, ip) except Exception as e: log.error(f"Sync error: {e}") # ── Entry point ──────────────────────────────────────────────────────────────── def main(): log.info( f"🚀 Hostinger DNS Sync starting — " f"domain={DOMAIN} ttl={TTL}s " f"delete_orphans={DELETE_ORPHANS} dry_run={DRY_RUN}" ) ip = get_public_ip() docker_client = docker.from_env() log.info("🔄 Initial sync on startup...") try: sync(docker_client, ip) except Exception as e: log.error(f"Initial sync failed: {e}") try: watch_events(docker_client, ip) except KeyboardInterrupt: log.info("Shutting down.") if __name__ == "__main__": main()