diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7b324d6 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# FritzBox TR-064 +FRITZBOX_HOST=192.168.178.1 +FRITZBOX_PORT=49000 +FRITZBOX_USER=dyndns-updater +FRITZBOX_PASSWORD=change-me + +# Cloudflare +CLOUDFLARE_API_TOKEN=change-me +CLOUDFLARE_ZONE_ID=change-me +# Kommagetrennte Liste der Subdomains (ohne Proxy!), Anzahl beliebig erweiterbar +CLOUDFLARE_RECORDS=sub1.example.org,sub2.example.org,sub3.example.org + +# FreeDNS (Fallback/Parallelbetrieb) +FREEDNS_UPDATE_URL=https://freedns.afraid.org/dynamic/update.php?TOKEN + +# Webhook (kein Auth, rein intern) +WEBHOOK_PORT=8090 + +# Fallback Polling +POLL_INTERVAL_MINUTES=15 + +# Healthchecks (self-hosted) +HEALTHCHECK_PING_URL=https://healthchecks.example.org/ping/change-me + +# Sonstiges +TZ=Europe/Berlin +LOG_LEVEL=INFO diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..61b5743 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,39 @@ +name: Build and Push + +on: + push: + branches: [main] + tags: ["v*.*.*"] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/dyndns-updater + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbc38a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +RUN useradd --create-home --shell /bin/bash appuser + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +RUN mkdir -p /data && chown -R appuser:appuser /app /data + +USER appuser + +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8090 + +CMD ["python", "-m", "app.main"] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..bd4e399 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,62 @@ +# DynDNS-Updater - Umsetzungsplan + +## Phase 0 - Vorbereitung +- [ ] Cloudflare API-Token neu anlegen (Template "Edit zone DNS") +- [ ] Cloudflare Zone ID kopieren +- [ ] Subdomain-Liste erfassen +- [ ] FritzBox TR-064-User anlegen und testen +- [ ] Healthchecks-Check anlegen (15 Min Periode) + +## Phase 1 - Repo +- [x] GitHub-Repo angelegt +- [x] Struktur + SPEC/PLAN committet + +## Phase 2 - Kernmodule +- [x] config.py, state.py, fritzbox.py, cloudflare.py, freedns.py, healthcheck.py, main.py + +## Phase 3 - Lokaler Test +- [ ] .env lokal befuellen (nicht committen) +- [ ] TR-064 gegen echte FritzBox testen +- [ ] Cloudflare-Update mit Test-Subdomain verifizieren +- [ ] FreeDNS-Call verifizieren +- [ ] Fehlerfaelle simulieren +- [ ] Webhook manuell triggern +- [ ] Idempotenz pruefen + +## Phase 4 - Containerisierung +- [x] Dockerfile, docker-compose.yml +- [ ] docker build/run lokal testen +- [ ] Vollen Zyklus im Container testen + +## Phase 5 - CI/Registry +- [x] build-and-push.yml +- [ ] GHCR-Sichtbarkeit pruefen +- [ ] Ersten Push/Actions-Lauf pruefen +- [ ] Tag v0.1.0 + +## Phase 6 - Deployment +- [ ] Zielverzeichnis auf Docker-Host anlegen +- [ ] compose + .env kopieren, docker login, up -d +- [ ] Logs und Healthcheck-Ping pruefen + +## Phase 7 - FritzBox-Integration +- [ ] Custom-DynDNS-URL konfigurieren +- [ ] Webhook testen +- [ ] FreeDNS-Parallelbetrieb bestaetigen + +## Phase 8 - Umstellung bestehender Subdomains +- [ ] Pro Subdomain einzeln: CNAME loeschen, A-Record (DNS-only) anlegen +- [ ] Erreichbarkeit von aussen pruefen +- [ ] Nicht-Standard-Port-Subdomains einzeln gegenchecken +- [ ] CLOUDFLARE_RECORDS final anpassen + +## Phase 9 - Monitoring & Abnahme +- [ ] Watchtower-Lauf pruefen +- [ ] Healthchecks-Alarmierung testen +- [ ] Eine Woche Beobachtung +- [ ] Doku aktualisieren + +## Phase 10 - Abschluss +- [ ] FreeDNS optional deaktivieren +- [ ] Proxy-Mode-Entscheidung erneut bewerten +- [ ] Redundanz pruefen diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e085b44 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,54 @@ +# DynDNS-Updater - Spezifikation + +**Status:** Entwurf fuer Umsetzung +**Zielumgebung:** Docker-Host (Debian VM auf Proxmox) +**Hosting:** GitHub (Repo + GitHub Actions) +**IP-Version:** Nur IPv4 (kein AAAA/IPv6 in v1) + +## 1. Ziel + +Abloesung des aktuellen Setups (Cloudflare-CNAME -> FreeDNS-Subdomain) durch einen selbst +gehosteten Updater-Container, der eine variable Anzahl Cloudflare-A-Records DNS-only (graue +Cloud) direkt mit der aktuellen oeffentlichen IPv4-Adresse pflegt und parallel weiterhin die +bestehende FreeDNS-Update-URL aufruft. + +Trigger fuer einen Update-Zyklus: +1. Container-Start (Initial-Check) +2. Webhook-Aufruf (GET) von der FritzBox bei IP-Wechsel +3. Fallback-Polling alle 15 Minuten + +Die tatsaechliche IP wird niemals aus Webhook-Parametern uebernommen. Der Container ermittelt +die IP bei jedem Trigger selbst ueber die FritzBox TR-064-Schnittstelle. + +## 2. Ablauflogik + +1. IP_aktuell = fritzbox.get_external_ip(retries=3, backoff=[2s,5s,10s]) +2. IP_letzte = state.load() +3. Wenn gleich: Poll -> nur Heartbeat-Ping, kein Update. Webhook/Start -> nur Log. +4. Wenn unterschiedlich: Cloudflare-Records updaten, FreeDNS aufrufen, state speichern, + Healthcheck-Erfolgs-Ping. Bei Teilfehler: state NICHT speichern, Fail-Ping. + +## 3. Cloudflare + +- Nur A-Records, DNS-only (proxied=false) +- Variable Subdomain-Liste ueber CLOUDFLARE_RECORDS (ENV) +- Records pro Lauf per Name aufgeloest, nicht gecacht +- Benoetigter Token-Scope: Zone -> DNS -> Edit (Template "Edit zone DNS") + Zone -> Zone -> Read + +## 4. Webhook + +- GET /webhook/update, kein Auth, nur fuer internen LAN-Aufruf gedacht + +## 5. Sicherheit + +- .env niemals committen +- Cloudflare-Token nur mit minimal noetigen Rechten +- FritzBox-TR-064-User mit eingeschraenkten Rechten +- Alle Records bleiben DNS-only (kein Proxy-Mode in v1) + +## 6. Offene Punkte + +- IPv6/AAAA +- Proxy-Mode fuer reine HTTP(S)-Subdomains +- Redundanter Lauf auf zweitem Host +- Spaetere Abschaltung der FreeDNS-Anbindung diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cloudflare.py b/app/cloudflare.py new file mode 100644 index 0000000..5041e7d --- /dev/null +++ b/app/cloudflare.py @@ -0,0 +1,64 @@ +import logging + +import requests + +log = logging.getLogger("cloudflare") + +API_BASE = "https://api.cloudflare.com/client/v4" + + +class CloudflareError(Exception): + pass + + +def _headers(api_token: str): + return { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + } + + +def _find_record_id(zone_id: str, api_token: str, name: str): + url = f"{API_BASE}/zones/{zone_id}/dns_records" + resp = requests.get(url, headers=_headers(api_token), params={"type": "A", "name": name}, timeout=15) + resp.raise_for_status() + data = resp.json() + results = data.get("result", []) + if not results: + return None + return results[0]["id"] + + +def update_records(zone_id: str, api_token: str, record_names, ip: str) -> dict: + results = {} + for name in record_names: + try: + record_id = _find_record_id(zone_id, api_token, name) + if record_id is None: + results[name] = "error: Record nicht gefunden (existiert der A-Record bereits?)" + log.error("Cloudflare-Record nicht gefunden: %s", name) + continue + + url = f"{API_BASE}/zones/{zone_id}/dns_records/{record_id}" + payload = { + "type": "A", + "name": name, + "content": ip, + "proxied": False, + "ttl": 60, + } + resp = requests.put(url, headers=_headers(api_token), json=payload, timeout=15) + resp.raise_for_status() + body = resp.json() + if not body.get("success"): + results[name] = f"error: {body.get('errors')}" + log.error("Cloudflare-Update fehlgeschlagen fuer %s: %s", name, body.get("errors")) + continue + + results[name] = "ok" + log.info("Cloudflare-Record aktualisiert: %s -> %s", name, ip) + except requests.RequestException as exc: + results[name] = f"error: {exc}" + log.error("Cloudflare-API-Fehler fuer %s: %s", name, exc) + + return results diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..7936dad --- /dev/null +++ b/app/config.py @@ -0,0 +1,59 @@ +import os +import sys +import logging + +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) + +log = logging.getLogger("config") + +REQUIRED_VARS = [ + "FRITZBOX_HOST", + "FRITZBOX_USER", + "FRITZBOX_PASSWORD", + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_ZONE_ID", + "CLOUDFLARE_RECORDS", +] + + +class Config: + def __init__(self): + self.fritzbox_host = os.environ.get("FRITZBOX_HOST", "192.168.178.1") + self.fritzbox_port = int(os.environ.get("FRITZBOX_PORT", "49000")) + self.fritzbox_user = os.environ.get("FRITZBOX_USER") + self.fritzbox_password = os.environ.get("FRITZBOX_PASSWORD") + + self.cloudflare_api_token = os.environ.get("CLOUDFLARE_API_TOKEN") + self.cloudflare_zone_id = os.environ.get("CLOUDFLARE_ZONE_ID") + records_raw = os.environ.get("CLOUDFLARE_RECORDS", "") + self.cloudflare_records = [r.strip() for r in records_raw.split(",") if r.strip()] + + self.freedns_update_url = os.environ.get("FREEDNS_UPDATE_URL", "") + + self.webhook_port = int(os.environ.get("WEBHOOK_PORT", "8090")) + self.poll_interval_minutes = int(os.environ.get("POLL_INTERVAL_MINUTES", "15")) + + self.healthcheck_ping_url = os.environ.get("HEALTHCHECK_PING_URL", "") + + self.state_path = os.environ.get("STATE_PATH", "/data/last-known-ip.json") + + def validate(self): + missing = [v for v in REQUIRED_VARS if not os.environ.get(v)] + if missing: + log.error("Fehlende Pflicht-ENV-Variablen: %s", ", ".join(missing)) + sys.exit(1) + if not self.cloudflare_records: + log.error("CLOUDFLARE_RECORDS ist leer - mindestens eine Subdomain angeben.") + sys.exit(1) + log.info( + "Konfiguration geladen: %d Cloudflare-Record(s), Poll-Intervall=%d Min, Webhook-Port=%d", + len(self.cloudflare_records), + self.poll_interval_minutes, + self.webhook_port, + ) + + +config = Config() diff --git a/app/freedns.py b/app/freedns.py new file mode 100644 index 0000000..c2bb247 --- /dev/null +++ b/app/freedns.py @@ -0,0 +1,19 @@ +import logging + +import requests + +log = logging.getLogger("freedns") + + +def update(update_url: str, timeout: int = 15) -> bool: + if not update_url: + log.info("Kein FREEDNS_UPDATE_URL konfiguriert - FreeDNS-Update wird ausgelassen.") + return True + try: + resp = requests.get(update_url, timeout=timeout) + resp.raise_for_status() + log.info("FreeDNS-Update aufgerufen, Antwort: %s", resp.text.strip()[:200]) + return True + except requests.RequestException as exc: + log.error("FreeDNS-Update fehlgeschlagen: %s", exc) + return False diff --git a/app/fritzbox.py b/app/fritzbox.py new file mode 100644 index 0000000..901a392 --- /dev/null +++ b/app/fritzbox.py @@ -0,0 +1,33 @@ +import logging +import time + +from fritzconnection import FritzConnection +from fritzconnection.core.exceptions import FritzConnectionException + +log = logging.getLogger("fritzbox") + + +class FritzBoxError(Exception): + pass + + +def get_external_ip(host: str, port: int, user: str, password: str, retries: int = 3, + backoff_seconds=(2, 5, 10)) -> str: + last_exc = None + for attempt in range(1, retries + 1): + try: + fc = FritzConnection(address=host, port=port, user=user, password=password) + result = fc.call_action("WANIPConn1", "GetExternalIPAddress") + ip = result.get("NewExternalIPAddress") + if not ip: + raise FritzBoxError("Leere Antwort von der FritzBox (kein NewExternalIPAddress)") + log.info("Externe IP von FritzBox erhalten: %s (Versuch %d/%d)", ip, attempt, retries) + return ip + except (FritzConnectionException, FritzBoxError, OSError) as exc: + last_exc = exc + log.warning("TR-064-Abfrage fehlgeschlagen (Versuch %d/%d): %s", attempt, retries, exc) + if attempt < retries: + sleep_time = backoff_seconds[min(attempt - 1, len(backoff_seconds) - 1)] + time.sleep(sleep_time) + + raise FritzBoxError(f"Konnte externe IP nach {retries} Versuchen nicht ermitteln: {last_exc}") diff --git a/app/healthcheck.py b/app/healthcheck.py new file mode 100644 index 0000000..9fcfab5 --- /dev/null +++ b/app/healthcheck.py @@ -0,0 +1,22 @@ +import logging + +import requests + +log = logging.getLogger("healthcheck") + + +def ping(ping_url: str, status: str = "success", message: str = ""): + if not ping_url: + log.debug("Kein HEALTHCHECK_PING_URL konfiguriert - Ping wird ausgelassen.") + return + + url = ping_url + if status in ("fail", "partial-fail"): + url = ping_url.rstrip("/") + "/fail" + + body = message or status + try: + requests.post(url, data=body.encode("utf-8"), timeout=10) + log.debug("Healthcheck-Ping gesendet (%s): %s", status, message) + except requests.RequestException as exc: + log.warning("Healthcheck-Ping fehlgeschlagen: %s", exc) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8c33ea9 --- /dev/null +++ b/app/main.py @@ -0,0 +1,112 @@ +import logging +import threading + +from apscheduler.schedulers.background import BackgroundScheduler +from flask import Flask + +from app.config import config +from app.fritzbox import FritzBoxError, get_external_ip +from app import state, cloudflare, freedns, healthcheck + +log = logging.getLogger("main") + +app = Flask(__name__) + +_lock = threading.Lock() + + +def run_cycle(trigger: str = "unknown"): + with _lock: + log.info("Starte Update-Zyklus (Trigger=%s)", trigger) + try: + current_ip = get_external_ip( + config.fritzbox_host, + config.fritzbox_port, + config.fritzbox_user, + config.fritzbox_password, + ) + except FritzBoxError as exc: + log.error("IP-Ermittlung fehlgeschlagen: %s", exc) + healthcheck.ping(config.healthcheck_ping_url, status="fail", message=str(exc)) + return + + last_ip = state.load(config.state_path) + + if current_ip == last_ip: + log.info("IP unveraendert (%s) - kein DNS-Update noetig.", current_ip) + if trigger == "poll": + healthcheck.ping( + config.healthcheck_ping_url, + status="success", + message=f"alive, ip unchanged ({current_ip})", + ) + return + + log.info("IP-Aenderung erkannt: %s -> %s", last_ip, current_ip) + + cf_results = cloudflare.update_records( + config.cloudflare_zone_id, + config.cloudflare_api_token, + config.cloudflare_records, + current_ip, + ) + cf_ok = all(v == "ok" for v in cf_results.values()) + + freedns_ok = freedns.update(config.freedns_update_url) + + if cf_ok and freedns_ok: + state.save(config.state_path, current_ip) + healthcheck.ping( + config.healthcheck_ping_url, + status="success", + message=f"IP updated to {current_ip}", + ) + else: + failed = {k: v for k, v in cf_results.items() if v != "ok"} + log.error( + "Update unvollstaendig - state wird NICHT gespeichert. " + "Cloudflare-Fehler: %s, FreeDNS ok=%s", + failed, + freedns_ok, + ) + healthcheck.ping( + config.healthcheck_ping_url, + status="partial-fail", + message=f"partial failure: cf_errors={failed} freedns_ok={freedns_ok}", + ) + + +@app.route("/webhook/update", methods=["GET"]) +def webhook_update(): + threading.Thread(target=run_cycle, kwargs={"trigger": "webhook"}, daemon=True).start() + return {"status": "triggered"}, 202 + + +@app.route("/healthz", methods=["GET"]) +def healthz(): + return {"status": "ok"}, 200 + + +def main(): + config.validate() + + log.info("Initial-Update-Zyklus beim Start...") + run_cycle(trigger="startup") + + scheduler = BackgroundScheduler() + scheduler.add_job( + run_cycle, + "interval", + minutes=config.poll_interval_minutes, + kwargs={"trigger": "poll"}, + id="fallback-poll", + ) + scheduler.start() + log.info("Fallback-Polling gestartet (alle %d Minuten).", config.poll_interval_minutes) + + log.info("Webhook-Server startet auf Port %d (GET /webhook/update, kein Auth).", config.webhook_port) + app.run(host="0.0.0.0", port=config.webhook_port) + + +if __name__ == "__main__": + main() diff --git a/app/state.py b/app/state.py new file mode 100644 index 0000000..d8b1944 --- /dev/null +++ b/app/state.py @@ -0,0 +1,25 @@ +import json +import logging +import os + +log = logging.getLogger("state") + + +def load(state_path: str): + if not os.path.exists(state_path): + log.info("Keine vorherige State-Datei gefunden (%s) - Erstlauf.", state_path) + return None + try: + with open(state_path, "r") as f: + data = json.load(f) + return data.get("last_ip") + except (json.JSONDecodeError, OSError) as exc: + log.warning("State-Datei konnte nicht gelesen werden (%s): %s", state_path, exc) + return None + + +def save(state_path: str, ip: str): + os.makedirs(os.path.dirname(state_path), exist_ok=True) + with open(state_path, "w") as f: + json.dump({"last_ip": ip}, f) + log.info("State gespeichert: last_ip=%s", ip) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5614787 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + dyndns-updater: + image: ghcr.io/skoelle/dyndns-updater:latest + container_name: dyndns-updater + restart: unless-stopped + env_file: + - .env + ports: + - "8090:8090" # Webhook-Port (GET, kein Auth) - intern im LAN + volumes: + - ./data:/data # last-known-ip.json Persistierung + labels: + - "com.centurylinklabs.watchtower.enable=true"