dyndns-docker/dyndns.py

267 lines
8.4 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
DynDNS Client for Hetzner DNS API
Automatically updates DNS records when IP address changes
"""
import os
import sys
import time
import logging
import requests
from typing import Optional, Dict, Any
class HetznerDynDNS:
"""Hetzner DNS API client for dynamic DNS updates"""
BASE_URL = "https://dns.hetzner.com/api/v1"
def __init__(
self,
api_token: str,
domain: str,
record_name: str,
record_type: str = "A",
zone_id: Optional[str] = None
):
"""
Initialize Hetzner DynDNS client
Args:
api_token: Hetzner DNS API token
domain: Domain name (e.g., example.com)
record_name: DNS record name (e.g., 'home' or '@' for root)
record_type: Record type ('A' for IPv4, 'AAAA' for IPv6)
zone_id: Optional zone ID (will be auto-detected if not provided)
"""
self.api_token = api_token
self.domain = domain
self.record_name = record_name if record_name != "@" else ""
self.record_type = record_type
self.zone_id = zone_id
self.headers = {
"Auth-API-Token": api_token,
"Content-Type": "application/json"
}
if not self.zone_id:
self.zone_id = self._get_zone_id()
def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Make HTTP request to Hetzner API"""
url = f"{self.BASE_URL}/{endpoint}"
try:
if method == "GET":
response = requests.get(url, headers=self.headers)
elif method == "POST":
response = requests.post(url, headers=self.headers, json=data)
elif method == "PUT":
response = requests.put(url, headers=self.headers, json=data)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logging.error(f"API request failed: {e}")
raise
def _get_zone_id(self) -> str:
"""Get zone ID for the domain"""
logging.info(f"Looking up zone ID for domain: {self.domain}")
try:
response = self._make_request("GET", "zones")
zones = response.get("zones", [])
for zone in zones:
if zone.get("name") == self.domain:
zone_id = zone.get("id")
logging.info(f"Found zone ID: {zone_id}")
return zone_id
raise ValueError(f"Zone not found for domain: {self.domain}")
except Exception as e:
logging.error(f"Failed to get zone ID: {e}")
raise
def get_current_ip(self) -> str:
"""Get current public IP address"""
try:
if self.record_type == "A":
# IPv4
response = requests.get("https://api.ipify.org", timeout=10)
else:
# IPv6
response = requests.get("https://api6.ipify.org", timeout=10)
response.raise_for_status()
ip = response.text.strip()
logging.debug(f"Current IP ({self.record_type}): {ip}")
return ip
except requests.exceptions.RequestException as e:
logging.error(f"Failed to get current IP: {e}")
raise
def get_dns_record(self) -> Optional[Dict[str, Any]]:
"""Get existing DNS record"""
try:
response = self._make_request("GET", f"records?zone_id={self.zone_id}")
records = response.get("records", [])
for record in records:
if (record.get("type") == self.record_type and
record.get("name") == self.record_name):
logging.debug(f"Found existing record: {record}")
return record
logging.info(f"No existing record found for {self.record_name}.{self.domain}")
return None
except Exception as e:
logging.error(f"Failed to get DNS record: {e}")
raise
def create_dns_record(self, ip: str) -> Dict[str, Any]:
"""Create new DNS record"""
logging.info(f"Creating new DNS record: {self.record_name}.{self.domain} -> {ip}")
data = {
"zone_id": self.zone_id,
"type": self.record_type,
"name": self.record_name,
"value": ip,
"ttl": 60
}
try:
response = self._make_request("POST", "records", data)
logging.info(f"DNS record created successfully")
return response
except Exception as e:
logging.error(f"Failed to create DNS record: {e}")
raise
def update_dns_record(self, record_id: str, ip: str) -> Dict[str, Any]:
"""Update existing DNS record"""
logging.info(f"Updating DNS record: {self.record_name}.{self.domain} -> {ip}")
data = {
"zone_id": self.zone_id,
"type": self.record_type,
"name": self.record_name,
"value": ip,
"ttl": 60
}
try:
response = self._make_request("PUT", f"records/{record_id}", data)
logging.info(f"DNS record updated successfully")
return response
except Exception as e:
logging.error(f"Failed to update DNS record: {e}")
raise
def update(self) -> bool:
"""
Update DNS record if IP has changed
Returns:
True if record was updated, False if no update was needed
"""
try:
current_ip = self.get_current_ip()
existing_record = self.get_dns_record()
if existing_record:
if existing_record.get("value") == current_ip:
logging.info(f"IP unchanged ({current_ip}), no update needed")
return False
else:
logging.info(f"IP changed: {existing_record.get('value')} -> {current_ip}")
self.update_dns_record(existing_record.get("id"), current_ip)
return True
else:
logging.info(f"No existing record, creating new one with IP: {current_ip}")
self.create_dns_record(current_ip)
return True
except Exception as e:
logging.error(f"Update failed: {e}")
return False
def setup_logging(log_level: str = "INFO") -> None:
"""Configure logging"""
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
def main():
"""Main entry point"""
# Load configuration from environment variables
api_token = os.getenv("HETZNER_API_TOKEN")
domain = os.getenv("DOMAIN")
record_name = os.getenv("RECORD_NAME", "@")
record_type = os.getenv("RECORD_TYPE", "A")
zone_id = os.getenv("ZONE_ID")
check_interval = int(os.getenv("CHECK_INTERVAL", "300"))
log_level = os.getenv("LOG_LEVEL", "INFO")
# Setup logging
setup_logging(log_level)
# Validate required configuration
if not api_token:
logging.error("HETZNER_API_TOKEN environment variable is required")
sys.exit(1)
if not domain:
logging.error("DOMAIN environment variable is required")
sys.exit(1)
logging.info("Starting Hetzner DynDNS client")
logging.info(f"Domain: {domain}")
logging.info(f"Record: {record_name if record_name else '@'}.{domain}")
logging.info(f"Type: {record_type}")
logging.info(f"Check interval: {check_interval} seconds")
# Initialize client
try:
client = HetznerDynDNS(
api_token=api_token,
domain=domain,
record_name=record_name,
record_type=record_type,
zone_id=zone_id
)
except Exception as e:
logging.error(f"Failed to initialize client: {e}")
sys.exit(1)
# Main loop
while True:
try:
client.update()
except KeyboardInterrupt:
logging.info("Shutting down...")
break
except Exception as e:
logging.error(f"Unexpected error: {e}")
# Wait before next check
time.sleep(check_interval)
if __name__ == "__main__":
main()