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,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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user