#!/usr/bin/env python3 """ Hostinger DNS Sync - Watches Docker/Traefik events and auto-manages DNS records """ import os import time import logging import re import requests 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 from env ──────────────────────────────────────────────────────────── HOSTINGER_API_KEY = os.environ["HOSTINGER_API_KEY"] DOMAIN = os.environ["DOMAIN"] # e.g. example.com PUBLIC_IP = os.environ.get("PUBLIC_IP", "") # override; auto-detected if empty RECORD_TYPE = os.environ.get("RECORD_TYPE", "A") TTL = int(os.environ.get("TTL", "3600")) POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30")) # seconds, for label polling TRAEFIK_LABEL = os.environ.get("TRAEFIK_LABEL", "traefik.http.routers.*.rule") DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true" HOSTINGER_BASE = "https://api.hostinger.com/v1" HEADERS = { "Authorization": f"Bearer {HOSTINGER_API_KEY}", "Content-Type": "application/json", } # ── Helpers ──────────────────────────────────────────────────────────────────── def get_public_ip() -> str: """Auto-detect public IP if not set.""" if PUBLIC_IP: return PUBLIC_IP for url in ["https://api.ipify.org", "https://checkip.amazonaws.com", "https://ifconfig.me"]: try: r = requests.get(url, timeout=5) ip = r.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.") def extract_hosts_from_rule(rule: str) -> list[str]: """ Parse Traefik router rule and extract hostnames. Handles: Host(`foo.example.com`) and Host(`a.x.com`, `b.x.com`) """ return re.findall(r'Host\(`([^`]+)`\)', rule) def get_subdomain(host: str) -> Optional[str]: """Return subdomain part if host belongs to DOMAIN, else None.""" if host == DOMAIN: return "@" if host.endswith(f".{DOMAIN}"): return host[: -(len(DOMAIN) + 1)] return None # ── Hostinger API ────────────────────────────────────────────────────────────── def list_dns_records() -> list[dict]: url = f"{HOSTINGER_BASE}/dns/zone/{DOMAIN}/records" r = requests.get(url, headers=HEADERS, timeout=10) r.raise_for_status() return r.json().get("data", []) def find_record(records: list[dict], name: str) -> Optional[dict]: for rec in records: if rec.get("name") == name and rec.get("type") == RECORD_TYPE: return rec return None def create_record(name: str, ip: str): if DRY_RUN: log.info(f"[DRY RUN] Would CREATE {RECORD_TYPE} {name}.{DOMAIN} -> {ip}") return url = f"{HOSTINGER_BASE}/dns/zone/{DOMAIN}/records" payload = {"type": RECORD_TYPE, "name": name, "content": ip, "ttl": TTL} r = requests.post(url, headers=HEADERS, json=payload, timeout=10) r.raise_for_status() log.info(f"✅ CREATED {RECORD_TYPE} {name}.{DOMAIN} -> {ip}") def update_record(record_id: int, name: str, ip: str): if DRY_RUN: log.info(f"[DRY RUN] Would UPDATE {RECORD_TYPE} {name}.{DOMAIN} -> {ip} (id={record_id})") return url = f"{HOSTINGER_BASE}/dns/zone/{DOMAIN}/records/{record_id}" payload = {"type": RECORD_TYPE, "name": name, "content": ip, "ttl": TTL} r = requests.put(url, headers=HEADERS, json=payload, timeout=10) r.raise_for_status() log.info(f"✅ UPDATED {RECORD_TYPE} {name}.{DOMAIN} -> {ip}") def delete_record(record_id: int, name: str): if DRY_RUN: log.info(f"[DRY RUN] Would DELETE {RECORD_TYPE} {name}.{DOMAIN} (id={record_id})") return url = f"{HOSTINGER_BASE}/dns/zone/{DOMAIN}/records/{record_id}" r = requests.delete(url, headers=HEADERS, timeout=10) r.raise_for_status() log.info(f"🗑️ DELETED {RECORD_TYPE} {name}.{DOMAIN}") def upsert_record(name: str, ip: str, existing_records: list[dict]): rec = find_record(existing_records, name) if rec: if rec.get("content") == ip: log.debug(f"⏭️ {name}.{DOMAIN} already points to {ip}, skipping") else: update_record(rec["id"], name, ip) else: create_record(name, ip) # ── Docker watcher ───────────────────────────────────────────────────────────── def get_traefik_hosts_from_containers(client: docker.DockerClient) -> set[str]: """Scan all running containers for Traefik host rules.""" hosts = set() for container in 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): hosts.add(host) return hosts def sync(client: docker.DockerClient, ip: str): """Main sync: compare desired hosts with current DNS, upsert/remove as needed.""" desired_hosts = get_traefik_hosts_from_containers(client) desired_subdomains = {} for host in desired_hosts: sub = get_subdomain(host) if sub: desired_subdomains[sub] = host else: log.debug(f"Skipping {host} — not under {DOMAIN}") if not desired_subdomains: log.info("No matching Traefik hosts found for this domain.") return log.info(f"Desired subdomains: {list(desired_subdomains.keys())}") existing_records = list_dns_records() # Upsert desired for name in desired_subdomains: upsert_record(name, ip, existing_records) # Optionally delete records no longer in use (opt-in via env) if os.environ.get("DELETE_ORPHANS", "false").lower() == "true": managed = set(desired_subdomains.keys()) for rec in existing_records: if rec.get("type") == RECORD_TYPE and rec.get("name") not in managed: log.info(f"Orphan record found: {rec['name']}.{DOMAIN}") delete_record(rec["id"], rec["name"]) # ── Event-driven loop ────────────────────────────────────────────────────────── def watch_events(client: docker.DockerClient, ip: str): """React to Docker start/stop/die events immediately.""" log.info("👂 Listening for Docker events...") for event in client.events(decode=True, filters={"type": "container"}): status = event.get("status", "") if status in ("start", "die", "stop", "destroy"): container_name = event.get("Actor", {}).get("Attributes", {}).get("name", "?") log.info(f"⚡ Event: {status} on {container_name} — triggering DNS sync") try: sync(client, ip) except Exception as e: log.error(f"Sync failed: {e}") # ── Entrypoint ───────────────────────────────────────────────────────────────── def main(): log.info(f"🚀 Hostinger DNS Sync starting — domain: {DOMAIN}, TTL: {TTL}s") if DRY_RUN: log.warning("⚠️ DRY RUN mode — no changes will be made") ip = get_public_ip() client = docker.from_env() # Initial sync on startup log.info("🔄 Initial sync...") try: sync(client, ip) except Exception as e: log.error(f"Initial sync failed: {e}") # Watch Docker events (blocking) try: watch_events(client, ip) except KeyboardInterrupt: log.info("Shutting down.") if __name__ == "__main__": main()