INITIAL COMMIT
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -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
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -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"]
|
||||||
115
README.md
Normal file
115
README.md
Normal file
@@ -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 <this-repo>
|
||||||
|
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.<name>.rule=Host(`subdomain.example.com`)
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-host rules are supported:
|
||||||
|
|
||||||
|
```
|
||||||
|
traefik.http.routers.<name>.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`.
|
||||||
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()
|
||||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
@@ -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"
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
docker==7.1.0
|
||||||
|
requests==2.32.3
|
||||||
Reference in New Issue
Block a user