fix: fixing the error [ERROR] Sync error: 'content'
This commit is contained in:
286
dns_sync.py
286
dns_sync.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user