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
+27
View File
@@ -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
+39
View File
@@ -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 }}
+20
View File
@@ -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"]
+62
View File
@@ -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
+54
View File
@@ -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
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)
+13
View File
@@ -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"