From 4e0640cb3c03bfd417cc3049ca2377149e31801d Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Sun, 19 Apr 2026 23:14:47 +0200 Subject: [PATCH] INITIAL COMMIT --- .env.example | 8 ++ Dockerfile | 15 ++++ README.md | 115 ++++++++++++++++++++++++ dns_sync.py | 217 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 47 ++++++++++ requirements.txt | 2 + 6 files changed, 404 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 dns_sync.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2097202 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Copy this file to .env and fill in your values +# Never commit .env to git! + +# Your Hostinger API key (from hPanel → API Tokens) +HOSTINGER_API_KEY=your_hostinger_api_key_here + +# Your root domain managed on Hostinger +DOMAIN=example.com diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0342be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +LABEL maintainer="hostinger-dns-sync" +LABEL description="Auto-sync Hostinger DNS records from Traefik Docker labels" + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY dns_sync.py . + +# docker.sock is mounted at runtime — no privileged flag needed +CMD ["python", "-u", "dns_sync.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0984325 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# Hostinger DNS Sync + +A lightweight Docker container that watches **Traefik router labels** on your Docker host and automatically creates/updates **Hostinger DNS records** to point your subdomains at your server's public IP. + +## How it works + +``` +Docker event (container start/stop) + │ + ▼ + Scan all containers for Traefik labels + e.g. traefik.http.routers.myapp.rule=Host(`app.example.com`) + │ + ▼ + Extract hostnames → filter by your DOMAIN + │ + ▼ + Call Hostinger DNS API → upsert A records +``` + +On startup it does a full sync, then it listens to Docker events in real-time and re-syncs whenever a container starts or stops. + +--- + +## Prerequisites + +- Docker + Docker Compose +- A domain on Hostinger with API access enabled +- Your server's public IP reachable from the internet + +--- + +## Setup + +### 1. Get your Hostinger API key + +1. Log in to [hPanel](https://hpanel.hostinger.com) +2. Go to **Profile → API Tokens** +3. Create a new token with DNS read/write permissions + +### 2. Clone & configure + +```bash +git clone +cd hostinger-dns-sync +cp .env.example .env +nano .env # fill in HOSTINGER_API_KEY and DOMAIN +``` + +### 3. Deploy + +```bash +docker compose up -d +``` + +Check logs: +```bash +docker compose logs -f hostinger-dns-sync +``` + +--- + +## Environment variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `HOSTINGER_API_KEY` | ✅ | — | API token from hPanel | +| `DOMAIN` | ✅ | — | Root domain, e.g. `example.com` | +| `PUBLIC_IP` | ❌ | auto-detected | Override server IP | +| `RECORD_TYPE` | ❌ | `A` | `A` (IPv4) or `AAAA` (IPv6) | +| `TTL` | ❌ | `3600` | DNS TTL in seconds | +| `DELETE_ORPHANS` | ❌ | `false` | Delete DNS records no longer in Traefik | +| `DRY_RUN` | ❌ | `false` | Preview changes without applying | + +--- + +## Traefik label format + +The service reads **all** containers with a label matching: + +``` +traefik.http.routers..rule=Host(`subdomain.example.com`) +``` + +Multi-host rules are supported: + +``` +traefik.http.routers..rule=Host(`a.example.com`) || Host(`b.example.com`) +``` + +--- + +## Orphan cleanup + +By default, the service only **adds/updates** records and never deletes them. +Set `DELETE_ORPHANS=true` to automatically remove DNS records for subdomains +that are no longer referenced by any Traefik router. + +⚠️ Use with care if you manage other records manually on the same domain. + +--- + +## Test without making changes + +```bash +DRY_RUN=true docker compose up hostinger-dns-sync +``` + +--- + +## Security notes + +- The Docker socket is mounted **read-only** (`:ro`) — the container cannot start/stop containers. +- The API key is passed via environment variable, never baked into the image. +- Add `.env` to your `.gitignore`. diff --git a/dns_sync.py b/dns_sync.py new file mode 100644 index 0000000..acea9e5 --- /dev/null +++ b/dns_sync.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d9dafc1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + + # ── Traefik (example — adapt to your existing setup) ──────────────────────── + traefik: + image: traefik:v3.1 + command: + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + ports: + - "80:80" + - "443:443" + - "8080:8080" # dashboard + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + + # ── Hostinger DNS Sync ─────────────────────────────────────────────────────── + hostinger-dns-sync: + build: . # use `image: ghcr.io/yourrepo/hostinger-dns-sync:latest` after pushing + container_name: hostinger-dns-sync + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro # read-only access to Docker events + environment: + # ── Required ────────────────────────────────────────────────────────────── + HOSTINGER_API_KEY: ${HOSTINGER_API_KEY} # set in .env + DOMAIN: ${DOMAIN} # e.g. example.com + + # ── Optional ────────────────────────────────────────────────────────────── + # PUBLIC_IP: "1.2.3.4" # override auto-detection + RECORD_TYPE: "A" # A or AAAA + TTL: "3600" + DELETE_ORPHANS: "false" # set "true" to remove DNS records no longer in Traefik + DRY_RUN: "false" # set "true" to preview changes without applying them + +# ── Example app (shows how Traefik labels work) ────────────────────────────── +# Uncomment to test end-to-end: +# +# whoami: +# image: traefik/whoami +# labels: +# - "traefik.enable=true" +# - "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)" +# - "traefik.http.routers.whoami.entrypoints=web" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e809397 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +docker==7.1.0 +requests==2.32.3