diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe36d33 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Git +.git +.gitignore + +# Environment +.env +.env.* +!.env.example + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ + +# IDE +.vscode +.idea +*.swp +*.swo + +# Documentation +README.md +CHANGELOG.md +CLAUDE.md + +# Docker +docker-compose.yml +docker-compose.override.yml +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..76e95f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Hetzner DNS API Token +# Erstellen Sie einen Token unter: https://dns.hetzner.com/ +HETZNER_API_TOKEN=your_api_token_here + +# Domain Name (z.B. example.com) +DOMAIN=example.com + +# DNS Record Name +# Verwenden Sie '@' für die Root-Domain oder einen Subdomain-Namen wie 'home' +RECORD_NAME=@ + +# DNS Record Typ +# A für IPv4, AAAA für IPv6 +RECORD_TYPE=A + +# Optional: Zone ID (wird automatisch ermittelt wenn leer) +ZONE_ID= + +# Prüfintervall in Sekunden (Standard: 300 = 5 Minuten) +CHECK_INTERVAL=300 + +# Log Level (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index cdb64f8..60d9264 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ venv.bak/ *.swp *.swo *~ +.claude/ # Logs *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index ec83b43..0dede74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). +## [0.2.0] - 2026-02-16 + +### Hinzugefügt +- Python-basierter DynDNS Client (`dyndns.py`) +- Integration mit Hetzner DNS API v1 +- Automatische IP-Erkennung (IPv4/IPv6) +- Automatische Zone-ID Ermittlung +- Dockerfile mit Multi-Stage Build und Non-Root User +- Docker Compose Konfiguration mit Restart-Policy +- Umgebungsvariablen-Konfiguration über `.env` +- Beispiel-Konfiguration (`.env.example`) +- Python Dependencies (`requirements.txt`) +- CLAUDE.md Entwicklerdokumentation +- Logging mit konfigurierbarem Log-Level +- Health Check für Container +- .dockerignore für optimierte Builds + +### Features +- Prüfung der IP-Änderung in konfigurierbaren Intervallen +- Automatische Erstellung von DNS Records bei Bedarf +- Automatische Aktualisierung bei IP-Änderung +- Unterstützung für IPv4 (A) und IPv6 (AAAA) Records +- Unterstützung für Root-Domain (@) und Subdomains + ## [0.1.0] - 2026-02-16 ### Hinzugefügt diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..08943df --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,147 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Docker-based Dynamic DNS client for the Hetzner DNS Console. It automatically detects IP changes and updates DNS records via the Hetzner DNS API. + +**Core Components:** +- `dyndns.py`: Main Python application that handles IP detection and DNS updates +- `Dockerfile`: Container configuration for the application +- `docker-compose.yml`: Docker Compose setup for easy deployment + +## Development Commands + +### Docker Operations + +**Build the container:** +```bash +docker-compose build +``` + +**Start the service:** +```bash +docker-compose up -d +``` + +**View logs:** +```bash +docker-compose logs -f +``` + +**Restart service:** +```bash +docker-compose restart +``` + +**Stop service:** +```bash +docker-compose down +``` + +**Rebuild after code changes:** +```bash +docker-compose up -d --build +``` + +### Local Development + +**Run Python script directly (requires Python 3.11+):** +```bash +# Install dependencies +pip install -r requirements.txt + +# Set environment variables or use .env file +export HETZNER_API_TOKEN="your_token" +export DOMAIN="example.com" +export RECORD_NAME="@" + +# Run script +python dyndns.py +``` + +**Test IP detection without updating DNS:** +```python +from dyndns import HetznerDynDNS +client = HetznerDynDNS(api_token, domain, record_name) +current_ip = client.get_current_ip() +print(f"Current IP: {current_ip}") +``` + +## Architecture + +### Application Flow + +1. **Initialization**: Load configuration from environment variables +2. **Zone Lookup**: Auto-detect Hetzner DNS Zone ID if not provided +3. **Main Loop**: + - Get current public IP (via api.ipify.org) + - Fetch existing DNS record from Hetzner API + - Compare IPs and update if changed + - Wait for configured interval (default: 5 minutes) + - Repeat + +### Hetzner DNS API Integration + +The application uses Hetzner's DNS API v1 (https://dns.hetzner.com/api/v1): +- **Authentication**: Via `Auth-API-Token` header +- **GET /zones**: Find zone ID for domain +- **GET /records**: List DNS records for zone +- **POST /records**: Create new DNS record +- **PUT /records/{id}**: Update existing DNS record + +### Configuration + +All configuration is via environment variables (see `.env.example`): +- **Required**: `HETZNER_API_TOKEN`, `DOMAIN` +- **Optional**: `RECORD_NAME` (default: @), `RECORD_TYPE` (default: A), `CHECK_INTERVAL` (default: 300), `LOG_LEVEL` (default: INFO) + +## Versioning Strategy + +This project uses a simplified versioning scheme: +- **0.1**: Large changes (new features, breaking changes) +- **0.0.1**: Small changes (bugfixes, minor improvements) +- **1.x**: Major releases (only by explicit instruction) + +**When making changes:** +1. Update `CHANGELOG.md` with the change description +2. Increment version according to change size +3. Work in feature branches, merge to main when ready + +## Key Files + +- `dyndns.py`: Core application logic (250+ lines) +- `requirements.txt`: Python dependencies +- `Dockerfile`: Multi-stage build with non-root user +- `docker-compose.yml`: Service configuration with restart policy +- `.env.example`: Configuration template +- `CHANGELOG.md`: Version history + +## Security Notes + +- API token is sensitive - never commit `.env` file +- Container runs as non-root user (UID 1000) +- Minimal Python slim image for reduced attack surface +- API token should have minimal required permissions (DNS read/write only) + +## Troubleshooting + +**Check if container is running:** +```bash +docker-compose ps +``` + +**View recent logs:** +```bash +docker-compose logs --tail=50 +``` + +**Test API connectivity:** +```bash +# From host +curl -H "Auth-API-Token: YOUR_TOKEN" https://dns.hetzner.com/api/v1/zones +``` + +**Debug mode:** +Set `LOG_LEVEL=DEBUG` in `.env` and restart container. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94a0a1f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY dyndns.py . + +# Make script executable +RUN chmod +x dyndns.py + +# Run as non-root user +RUN useradd -m -u 1000 dyndns && \ + chown -R dyndns:dyndns /app + +USER dyndns + +# Health check +HEALTHCHECK --interval=5m --timeout=10s --start-period=30s --retries=3 \ + CMD python -c "import sys; sys.exit(0)" + +# Run the application +CMD ["python", "-u", "dyndns.py"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e27e593 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + dyndns: + build: . + container_name: hetzner-dyndns + restart: unless-stopped + env_file: + - .env + environment: + - HETZNER_API_TOKEN=${HETZNER_API_TOKEN} + - DOMAIN=${DOMAIN} + - RECORD_NAME=${RECORD_NAME:-@} + - RECORD_TYPE=${RECORD_TYPE:-A} + - ZONE_ID=${ZONE_ID:-} + - CHECK_INTERVAL=${CHECK_INTERVAL:-300} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/dyndns.py b/dyndns.py new file mode 100644 index 0000000..e698726 --- /dev/null +++ b/dyndns.py @@ -0,0 +1,266 @@ +#!/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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0