mirror of
https://github.com/skoelle/dyndns-updater.git
synced 2026-09-17 16:10:25 +00:00
Add SPEC, PLAN, Dockerfile, compose, env example, app modules, CI workflow
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
+112
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user