Files
dns_sync/dns_sync.py

252 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()