diff --git a/.env.example b/.env.example index 7b324d6..e889a6a 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,8 @@ 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 +# Kommagetrennte Subdomain-Teile relativ zur Zone (kein FQDN!), z.B. "sub1" oder "*.home" +CLOUDFLARE_RECORDS=sub1,sub2,sub3 # FreeDNS (Fallback/Parallelbetrieb) FREEDNS_UPDATE_URL=https://freedns.afraid.org/dynamic/update.php?TOKEN diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..4f704a5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,18 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check . + - run: ruff format --check . diff --git a/README.md b/README.md index 8dec321..6f9accd 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Details siehe [SPEC.md](SPEC.md) (Architektur/Design) und [PLAN.md](PLAN.md) ```bash cp .env.example .env -vim .env # Werte eintragen +vim .env # Werte eintragen (CLOUDFLARE_RECORDS: nur Subdomain-Teile, z.B. "sub1,sub2,*.home") docker compose up -d ``` diff --git a/SPEC.md b/SPEC.md index e085b44..12e3683 100644 --- a/SPEC.md +++ b/SPEC.md @@ -31,7 +31,7 @@ die IP bei jedem Trigger selbst ueber die FritzBox TR-064-Schnittstelle. ## 3. Cloudflare - Nur A-Records, DNS-only (proxied=false) -- Variable Subdomain-Liste ueber CLOUDFLARE_RECORDS (ENV) +- Subdomain-Teile ueber CLOUDFLARE_RECORDS (ENV), relativ zur Zone (z.B. "sub1" oder "*.home", kein FQDN) - Records pro Lauf per Name aufgeloest, nicht gecacht - Benoetigter Token-Scope: Zone -> DNS -> Edit (Template "Edit zone DNS") + Zone -> Zone -> Read diff --git a/app/cloudflare.py b/app/cloudflare.py index 5041e7d..76ec9ee 100644 --- a/app/cloudflare.py +++ b/app/cloudflare.py @@ -1,3 +1,9 @@ +"""Cloudflare DNS-Update-Modul. + +Erwartet Subdomain-Teile relativ zur Zone (z.B. "sub1" oder "*.home"), +keine FQDNs (z.B. NICHT "sub1.example.org"). +""" + import logging import requests @@ -20,12 +26,20 @@ def _headers(api_token: str): 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 = 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 + if len(results) > 1: + log.warning( + "Mehrere A-Records fuer '%s' gefunden (%d), nur erster wird aktualisiert.", + name, + len(results), + ) return results[0]["id"] diff --git a/app/config.py b/app/config.py index 7936dad..4f79b13 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,6 @@ +import logging import os import sys -import logging logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO"), diff --git a/app/fritzbox.py b/app/fritzbox.py index 901a392..41d492e 100644 --- a/app/fritzbox.py +++ b/app/fritzbox.py @@ -11,8 +11,9 @@ 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: +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: diff --git a/app/healthcheck.py b/app/healthcheck.py index 9fcfab5..a6f3b8e 100644 --- a/app/healthcheck.py +++ b/app/healthcheck.py @@ -16,7 +16,7 @@ def ping(ping_url: str, status: str = "success", message: str = ""): body = message or status try: - requests.post(url, data=body.encode("utf-8"), timeout=10) + requests.post(url, data=body.encode("utf-8"), timeout=5) 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 index 8c33ea9..1fb1c72 100644 --- a/app/main.py +++ b/app/main.py @@ -1,18 +1,20 @@ +import concurrent.futures import logging import threading from apscheduler.schedulers.background import BackgroundScheduler from flask import Flask +from app import cloudflare, freedns, healthcheck, state 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() +_executor = concurrent.futures.ThreadPoolExecutor(max_workers=2) def run_cycle(trigger: str = "unknown"): @@ -32,7 +34,9 @@ def run_cycle(trigger: str = "unknown"): last_ip = state.load(config.state_path) - if current_ip == last_ip: + if last_ip is None: + log.info("Erstlauf - IP wird gesetzt: %s", current_ip) + elif current_ip == last_ip: log.info("IP unveraendert (%s) - kein DNS-Update noetig.", current_ip) if trigger == "poll": healthcheck.ping( @@ -78,7 +82,7 @@ def run_cycle(trigger: str = "unknown"): @app.route("/webhook/update", methods=["GET"]) def webhook_update(): - threading.Thread(target=run_cycle, kwargs={"trigger": "webhook"}, daemon=True).start() + _executor.submit(run_cycle, trigger="webhook") return {"status": "triggered"}, 202 @@ -104,7 +108,9 @@ def main(): 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) + 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) diff --git a/app/state.py b/app/state.py index d8b1944..7ac3976 100644 --- a/app/state.py +++ b/app/state.py @@ -10,7 +10,7 @@ def load(state_path: str): log.info("Keine vorherige State-Datei gefunden (%s) - Erstlauf.", state_path) return None try: - with open(state_path, "r") as f: + with open(state_path) as f: data = json.load(f) return data.get("last_ip") except (json.JSONDecodeError, OSError) as exc: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e04eb3d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "PL", "T20"] +ignore = ["E501", "PLR0913", "PLR0917"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..fdb8abf --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +ruff>=0.6.0