Add SPEC, PLAN, Dockerfile, compose, env example, app modules, CI workflow

This commit is contained in:
Stefan Koelle
2026-08-07 00:18:57 +02:00
parent d456a856e3
commit 6dd541b798
14 changed files with 549 additions and 0 deletions
View File
+64
View File
@@ -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
+59
View File
@@ -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()
+19
View File
@@ -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
+33
View File
@@ -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}")
+22
View File
@@ -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
View File
@@ -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()
+25
View File
@@ -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)