fix: fixing the error [ERROR] Sync error: 'content'

This commit is contained in:
2026-04-19 23:42:14 +02:00
parent 42a8f0fe98
commit dcee41ee9c

View File

@@ -2,16 +2,24 @@
""" """
Hostinger DNS Sync — watches Docker/Traefik events and auto-manages DNS records Hostinger DNS Sync — watches Docker/Traefik events and auto-manages DNS records
using the official hostinger_api Python SDK. using the official hostinger_api Python SDK.
The Hostinger API returns records in this shape:
[{ "name": "sub", "type": "A", "ttl": 3600, "records": [{"content": "1.2.3.4"}] }]
For the PUT update, each zone entry has the same shape with an "overwrite" flag.
""" """
import os import os
import re import re
import json
import logging import logging
import requests import requests
import hostinger_api import hostinger_api
from hostinger_api.rest import ApiException from hostinger_api.rest import ApiException
import docker from collections import defaultdict
from pathlib import Path
from typing import Optional from typing import Optional
import docker
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -22,34 +30,44 @@ log = logging.getLogger(__name__)
# ── Config ───────────────────────────────────────────────────────────────────── # ── Config ─────────────────────────────────────────────────────────────────────
HOSTINGER_API_KEY = os.environ["HOSTINGER_API_KEY"] HOSTINGER_API_KEY = os.environ["HOSTINGER_API_KEY"]
DOMAIN = os.environ["DOMAIN"] # e.g. example.com DOMAIN = os.environ["DOMAIN"]
PUBLIC_IP = os.environ.get("PUBLIC_IP", "") # auto-detected if empty PUBLIC_IP = os.environ.get("PUBLIC_IP", "")
RECORD_TYPE = os.environ.get("RECORD_TYPE", "A") RECORD_TYPE = os.environ.get("RECORD_TYPE", "A")
TTL = int(os.environ.get("TTL", "3600")) TTL = int(os.environ.get("TTL", "3600"))
DELETE_ORPHANS = os.environ.get("DELETE_ORPHANS", "false").lower() == "true" DELETE_ORPHANS = os.environ.get("DELETE_ORPHANS", "false").lower() == "true"
DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true" DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true"
# ── SDK client setup ─────────────────────────────────────────────────────────── # Only subdomains that this tool has ever CREATED are eligible for deletion.
configuration = hostinger_api.Configuration( STATE_FILE = Path(os.environ.get("STATE_FILE", "/data/managed_records.json"))
access_token=HOSTINGER_API_KEY
) # ── SDK client ─────────────────────────────────────────────────────────────────
configuration = hostinger_api.Configuration(access_token=HOSTINGER_API_KEY)
def dns_client() -> hostinger_api.DNSZoneApi: def dns_client() -> hostinger_api.DNSZoneApi:
client = hostinger_api.ApiClient(configuration) return hostinger_api.DNSZoneApi(hostinger_api.ApiClient(configuration))
return hostinger_api.DNSZoneApi(client)
# ── Public IP detection ──────────────────────────────────────────────────────── # ── State persistence ──────────────────────────────────────────────────────────
def load_managed() -> set[str]:
try:
return set(json.loads(STATE_FILE.read_text()))
except Exception:
return set()
def save_managed(names: set[str]):
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(sorted(names)))
# ── Public IP ──────────────────────────────────────────────────────────────────
def get_public_ip() -> str: def get_public_ip() -> str:
if PUBLIC_IP: if PUBLIC_IP:
return PUBLIC_IP return PUBLIC_IP
for url in [ for url in ["https://api.ipify.org", "https://checkip.amazonaws.com", "https://ifconfig.me"]:
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ifconfig.me",
]:
try: try:
ip = requests.get(url, timeout=5).text.strip() ip = requests.get(url, timeout=5).text.strip()
log.info(f"Auto-detected public IP: {ip}") log.info(f"Auto-detected public IP: {ip}")
@@ -59,15 +77,13 @@ def get_public_ip() -> str:
raise RuntimeError("Could not auto-detect public IP. Set PUBLIC_IP env var.") raise RuntimeError("Could not auto-detect public IP. Set PUBLIC_IP env var.")
# ── Traefik label parsing ────────────────────────────────────────────────────── # ── Docker / Traefik scanning ──────────────────────────────────────────────────
def extract_hosts_from_rule(rule: str) -> list[str]: 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) return re.findall(r"Host\(`([^`]+)`\)", rule)
def get_subdomain(host: str) -> Optional[str]: def get_subdomain(host: str) -> Optional[str]:
"""Return the subdomain name for this domain, or None if unrelated."""
if host == DOMAIN: if host == DOMAIN:
return "@" return "@"
if host.endswith(f".{DOMAIN}"): if host.endswith(f".{DOMAIN}"):
@@ -76,9 +92,13 @@ def get_subdomain(host: str) -> Optional[str]:
def get_desired_subdomains(docker_client: docker.DockerClient) -> dict[str, str]: def get_desired_subdomains(docker_client: docker.DockerClient) -> dict[str, str]:
"""Scan running containers → return {subdomain_name: full_hostname}.""" """
Scan ALL containers (running + stopped) for Traefik router rules.
Returns {subdomain_name: full_hostname}.
Scanning stopped containers avoids deleting records for briefly-restarting services.
"""
result = {} result = {}
for container in docker_client.containers.list(): for container in docker_client.containers.list(all=True):
labels = container.labels or {} labels = container.labels or {}
for key, value in labels.items(): for key, value in labels.items():
if re.match(r"traefik\.http\.routers\.[^.]+\.rule", key): if re.match(r"traefik\.http\.routers\.[^.]+\.rule", key):
@@ -91,95 +111,159 @@ def get_desired_subdomains(docker_client: docker.DockerClient) -> dict[str, str]
return result return result
# ── DNS sync logic ───────────────────────────────────────────────────────────── # ── Hostinger DNS ──────────────────────────────────────────────────────────────
def fetch_current_records() -> list[dict]: def fetch_current_records() -> list[dict]:
"""Return all DNS records for the domain as plain dicts.""" """
api = dns_client() Returns records as plain dicts. The SDK structure per record is:
{ "name": "sub", "type": "A", "ttl": 3600, "records": [{"content": "1.2.3.4"}] }
"""
try: try:
records = api.get_dns_records_v1(DOMAIN) raw = dns_client().get_dns_records_v1(DOMAIN)
# SDK returns a list of DNSV1ZoneRecordResource objects result = []
return [r.to_dict() for r in records] for r in raw:
d = r.to_dict()
# Log full structure on first record to help diagnose schema issues
if not result:
log.debug(f"SDK record sample: {d}")
result.append(d)
return result
except ApiException as e: except ApiException as e:
log.error(f"Failed to fetch DNS records: {e}") log.error(f"Failed to fetch DNS records: {e}")
raise raise
def build_updated_zone( def get_record_ip(rec: dict) -> Optional[str]:
current_records: list[dict],
desired: dict[str, str],
ip: str,
) -> list[dict]:
""" """
Merge current zone with desired state. Safely extract the IP from a record dict regardless of SDK nesting.
The Hostinger API uses a full-zone PUT — we must include ALL records we want Handles both flat {"content": "..."} and nested {"records": [{"content": "..."}]}.
to keep, not just the ones we're changing.
""" """
# Index existing target-type records by name # Nested form: {"records": [{"content": "1.2.3.4"}]}
existing_by_name = { if "records" in rec and isinstance(rec["records"], list) and rec["records"]:
first = rec["records"][0]
if isinstance(first, dict):
return first.get("content")
# SDK may return objects instead of dicts
if hasattr(first, "content"):
return first.content
# Flat form: {"content": "1.2.3.4"}
return rec.get("content")
def build_zone_entry(name: str, ip: str) -> hostinger_api.DNSV1ZoneUpdateRequestZoneInner:
return hostinger_api.DNSV1ZoneUpdateRequestZoneInner(
type=RECORD_TYPE,
name=name,
records=[
hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner(
content=ip,
ttl=TTL,
)
],
)
def rebuild_zone_entry_from_existing(rec: dict) -> hostinger_api.DNSV1ZoneUpdateRequestZoneInner:
"""Re-encode an existing record (any type) back into an SDK update object."""
# Extract the list of sub-records
sub_records = []
raw_recs = rec.get("records", [])
if raw_recs:
for r in raw_recs:
if isinstance(r, dict):
sub_records.append(
hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner(
content=r.get("content", ""),
ttl=r.get("ttl", rec.get("ttl", TTL)),
)
)
elif hasattr(r, "content"):
sub_records.append(
hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner(
content=r.content,
ttl=getattr(r, "ttl", TTL),
)
)
else:
# Flat record (shouldn't happen with real SDK but be safe)
content = rec.get("content", "")
if content:
sub_records.append(
hostinger_api.DNSV1ZoneUpdateRequestZoneInnerRecordsInner(
content=content,
ttl=rec.get("ttl", TTL),
)
)
return hostinger_api.DNSV1ZoneUpdateRequestZoneInner(
type=rec["type"],
name=rec["name"],
records=sub_records,
)
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: {sorted(desired.keys())}")
managed = load_managed()
current_records = fetch_current_records()
# Index existing records of our type by name
existing_ours = {
r["name"]: r r["name"]: r
for r in current_records for r in current_records
if r.get("type") == RECORD_TYPE if r.get("type") == RECORD_TYPE
} }
# All other record types — always preserved verbatim
# Records of other types we must always preserve
other_records = [r for r in current_records if r.get("type") != RECORD_TYPE] other_records = [r for r in current_records if r.get("type") != RECORD_TYPE]
updated_a_records = [] zone_entries = []
new_managed = set(managed)
# Upsert desired records # ── Upsert desired records ──────────────────────────────────────────────
for name in desired: for name in desired:
if name in existing_by_name: if name in existing_ours:
if existing_by_name[name].get("content") == ip: current_ip = get_record_ip(existing_ours[name])
log.debug(f"{name}.{DOMAIN}{ip} (no change)") if current_ip == ip:
log.debug(f"{name}.{DOMAIN}{ip} (unchanged)")
else: else:
log.info(f"✏️ UPDATE {RECORD_TYPE} {name}.{DOMAIN}{ip}") log.info(f"✏️ UPDATE {RECORD_TYPE} {name}.{DOMAIN}: {current_ip}{ip}")
else: else:
log.info(f" CREATE {RECORD_TYPE} {name}.{DOMAIN}{ip}") log.info(f" CREATE {RECORD_TYPE} {name}.{DOMAIN}{ip}")
updated_a_records.append({"type": RECORD_TYPE, "name": name, "content": ip, "ttl": TTL}) zone_entries.append(build_zone_entry(name, ip))
new_managed.add(name)
# Handle records not in desired set # ── Handle existing records NOT in desired ──────────────────────────────
for name, rec in existing_by_name.items(): for name, rec in existing_ours.items():
if name not in desired: if name in desired:
if DELETE_ORPHANS: continue
if DELETE_ORPHANS and name in managed:
# Safe: this tool created it and it's no longer needed
log.info(f"🗑️ DELETE orphan {RECORD_TYPE} {name}.{DOMAIN}") log.info(f"🗑️ DELETE orphan {RECORD_TYPE} {name}.{DOMAIN}")
# Omitting it from the list removes it on PUT new_managed.discard(name)
# Omitted from zone_entries → removed by PUT
else: else:
log.debug(f"⏭ Keeping unmanaged record {name}.{DOMAIN}") if DELETE_ORPHANS and name not in managed:
updated_a_records.append(rec) log.warning(
f"⚠️ KEEPING {name}.{DOMAIN} — not in managed set "
return other_records + updated_a_records f"(was created manually). "
f"To allow deletion, add it to {STATE_FILE}."
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,
)
) )
else:
log.debug(f"⏭ Keeping {name}.{DOMAIN}")
zone_entries.append(rebuild_zone_entry_from_existing(rec))
# ── Always re-include all other record types ────────────────────────────
for rec in other_records:
zone_entries.append(rebuild_zone_entry_from_existing(rec))
# ── Apply ───────────────────────────────────────────────────────────────
update_request = hostinger_api.DNSV1ZoneUpdateRequest(zone=zone_entries) update_request = hostinger_api.DNSV1ZoneUpdateRequest(zone=zone_entries)
if DRY_RUN: if DRY_RUN:
@@ -187,25 +271,15 @@ def apply_zone(full_zone: list[dict]):
return return
try: try:
api.update_dns_records_v1(DOMAIN, update_request) dns_client().update_dns_records_v1(DOMAIN, update_request)
log.info(f"✅ Zone updated — {len(zone_entries)} record group(s)") log.info(f"✅ Zone updated — {len(zone_entries)} record group(s)")
except ApiException as e: except ApiException as e:
log.error(f"Failed to update DNS zone: {e}") log.error(f"Failed to apply zone update: {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 return
log.info(f"Desired subdomains: {list(desired.keys())}") if new_managed != managed:
save_managed(new_managed)
current_records = fetch_current_records() log.debug(f"Managed state saved: {sorted(new_managed)}")
new_zone = build_updated_zone(current_records, desired, ip)
apply_zone(new_zone)
# ── Docker event loop ────────────────────────────────────────────────────────── # ── Docker event loop ──────────────────────────────────────────────────────────
@@ -216,30 +290,34 @@ def watch_events(docker_client: docker.DockerClient, ip: str):
status = event.get("status", "") status = event.get("status", "")
if status in ("start", "die", "stop", "destroy"): if status in ("start", "die", "stop", "destroy"):
name = event.get("Actor", {}).get("Attributes", {}).get("name", "?") name = event.get("Actor", {}).get("Attributes", {}).get("name", "?")
log.info(f"⚡ Event [{status}] on container '{name}' — syncing DNS...") log.info(f"⚡ Event [{status}] on '{name}' — syncing DNS...")
try: try:
sync(docker_client, ip) sync(docker_client, ip)
except Exception as e: except Exception as e:
log.error(f"Sync error: {e}") log.error(f"Sync error: {e}", exc_info=True)
# ── Entry point ──────────────────────────────────────────────────────────────── # ── Entry point ────────────────────────────────────────────────────────────────
def main(): def main():
log.info( log.info(
f"🚀 Hostinger DNS Sync starting — " f"🚀 Hostinger DNS Sync — domain={DOMAIN} ttl={TTL}s "
f"domain={DOMAIN} ttl={TTL}s "
f"delete_orphans={DELETE_ORPHANS} dry_run={DRY_RUN}" f"delete_orphans={DELETE_ORPHANS} dry_run={DRY_RUN}"
) )
if DELETE_ORPHANS:
log.info(
f"🛡 Orphan deletion ON — only records in {STATE_FILE} "
f"(created by this tool) are ever deleted."
)
ip = get_public_ip() ip = get_public_ip()
docker_client = docker.from_env() docker_client = docker.from_env()
log.info("🔄 Initial sync on startup...") log.info("🔄 Initial sync...")
try: try:
sync(docker_client, ip) sync(docker_client, ip)
except Exception as e: except Exception as e:
log.error(f"Initial sync failed: {e}") log.error(f"Initial sync failed: {e}", exc_info=True)
try: try:
watch_events(docker_client, ip) watch_events(docker_client, ip)