INITIAL COMMIT
This commit is contained in:
217
dns_sync.py
Normal file
217
dns_sync.py
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user