252 lines
9.0 KiB
Python
252 lines
9.0 KiB
Python
#!/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()
|