feat: alle FritzBoxen sichern (ueberspringt statt abzubrechen) + Tastenbestaetigung anzeigen/schalten

Satellit:
- fritzbox.backup_all: alle Boxen mit hinterlegtem Login nacheinander;
  nicht erreichbar (Port 49000), Fehler oder Taste noetig → notieren und
  weiter. Ergebnis: x von y gesichert + Grund je Box.
- fritzbox.second_factor: ist die Zusatzbestaetigung an, gibt es sie, und
  laesst sie sich per TR-064 schalten (nur wenn die Firmware eine Set-
  Aktion mit Enable-Argument im Auth-Dienst anbietet).
- fritzbox.second_factor_set: schaltet sie (confirmed, Taste per
  button_wait); sonst not_switchable + Weg ueber die Oberflaeche.

Diagnostic (💾-Fenster): Zeile "Tastenbestaetigung: an/aus" mit Aus-/
Einschalten (Rueckfrage, Countdown; nicht schaltbar → Hinweisfenster),
Knopf "Alle FritzBoxen sichern" mit Ergebnis je Box. Ausschalten/
Deaktivieren gilt jetzt als gefaehrliche Rueckfrage (roter Knopf).

Brain: Beschreibung fuer backup_all (je Satellit, weiter bei Fehlern) und
second_factor; Timeout 15 min fuer backup_all.

Getestet gegen nachgebaute Box (eine Box unerreichbar → uebersprungen,
Taste noetig ohne/mit Warten, Status, Schalten mit und ohne Setter) und das
Fenster in headless Chromium. Ob echte FRITZ!OS-Versionen das Schalten
per TR-064 anbieten, ist ungeprueft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:55:06 +01:00
co-authored by Claude Opus 5.5
parent 2bfa064ef6
commit 236e94da4a
3 changed files with 145 additions and 7 deletions
+87 -1
View File
@@ -875,6 +875,8 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
return await loop.run_in_executor(None, _do_snmp_ports, params)
if action == "snmp.info":
return await loop.run_in_executor(None, _do_snmp_info, params)
if action == "fritzbox.backup_all":
return await loop.run_in_executor(None, _fb_backup_all, params, devices)
if action.startswith("fritzbox."):
# Geraetename ("Fritzbox Keller") → IP, wie bei den anderen Aktionen
t = _snmp_target(params)
@@ -1716,6 +1718,86 @@ def _fb_restore(ip: str, user: str, pw: str, params: dict) -> dict:
"den Satelliten? In ein paar Minuten pruefen, ob die Einstellungen uebernommen sind.")}}
def _fb_reachable(ip: str, timeout: float = 3.0) -> bool:
try:
with socket.create_connection((ip, 49000), timeout=timeout):
return True
except OSError:
return False
def _fb_backup_all(params: dict, devices: list) -> dict:
"""Sichert ALLE FritzBoxen mit hinterlegtem Login, eine nach der anderen.
Nicht erreichbar / Fehler / Taste noetig → notieren und WEITER, nie abbrechen."""
names = {d.get("ip"): d.get("name") for d in devices if d.get("ip")}
boxes = [ip for ip, e in sorted(_CREDS.items()) if (e.get("fritzbox") or {}).get("pass")]
if not boxes:
return {"ok": False, "error": "An diesem Satelliten ist fuer keine FritzBox ein Login hinterlegt."}
report = []
for ip in boxes:
name = names.get(ip) or ip
if not _fb_reachable(ip):
report.append({"box": name, "ip": ip, "ok": False, "grund": "nicht erreichbar (aus/weg?) — uebersprungen"})
continue
fb = _creds_for(ip).get("fritzbox", {})
try:
r = _fb_backup(ip, str(fb.get("user") or ""), str(fb.get("pass") or ""),
{**params, "name": name, "no_file": True})
except Exception as exc:
r = {"ok": False, "error": f"Fehler: {exc}"}
if r.get("ok"):
report.append({"box": name, "ip": ip, "ok": True, "datei": os.path.basename(r["result"]["datei"])})
else:
grund = ("braucht eine Taste an der Box — uebersprungen (mit button_wait einzeln nachholen)"
if r.get("needs_button") else r.get("error") or "Fehler")
report.append({"box": name, "ip": ip, "ok": False, "grund": grund})
ok = sum(1 for x in report if x["ok"])
return {"ok": True, "result": {"gesichert": ok, "von": len(report), "boxen": report}}
# ─── Zusatzbestaetigung (Taste) der Box: Status + Umschalten ───
def _fb_second_factor_info(ip: str, user: str, pw: str) -> dict:
"""Ist die Tastenbestaetigung an? Und laesst sie sich per TR-064 schalten?
Umschalten geht nur, wenn die Firmware dafuer eine Aktion anbietet (eine
Set-Aktion des Auth-Dienstes mit einem Enable-Argument) — sonst nur in der
FritzBox-Oberflaeche."""
auth = _fb_find_service(ip, "X_AVM-DE_Auth")
if not auth:
return {"ok": True, "result": {"vorhanden": False, "an": False, "schaltbar": False,
"hinweis": "Die Box kennt keine Zusatzbestaetigung per Schnittstelle — Aktionen laufen ohne Taste."}}
acts = _fb_actions(ip, auth)
setter = next((a for a in acts if not _fb_is_read(a["name"]) and a["name"] != "SetConfig"
and any("enable" in x.lower() for x in a["in"])), None)
info = _tr064(ip, user, pw, auth["type"], auth["control"], "GetInfo") \
if any(a["name"] == "GetInfo" for a in acts) else {"ok": False}
f = info.get("fields", {}) if info.get("ok") else {}
en = f.get("NewEnabled", f.get("NewEnable"))
return {"ok": True, "result": {
"vorhanden": True, "an": None if en is None else en in ("1", "true"),
"schaltbar": bool(setter), "aktion": setter["name"] if setter else "",
"hinweis": ("" if setter else
"Laesst sich ueber die Schnittstelle nicht umschalten (Firmware bietet es nicht an). "
"Nur in der FritzBox-Oberflaeche: System → FRITZ!Box-Benutzer → Anmeldung im Heimnetz → "
"\"Ausfuehrung bestimmter Einstellungen und Funktionen zusaetzlich bestaetigen\". "
"Manche Firmware erlaubt das Abschalten gar nicht mehr.")}}
def _fb_second_factor_set(ip: str, user: str, pw: str, params: dict) -> dict:
inf = _fb_second_factor_info(ip, user, pw)["result"]
if not inf["vorhanden"]:
return {"ok": False, "error": inf["hinweis"]}
if not inf["schaltbar"]:
return {"ok": False, "not_switchable": True, "error": inf["hinweis"]}
auth = _fb_find_service(ip, "X_AVM-DE_Auth")
act = next(a for a in _fb_actions(ip, auth) if a["name"] == inf["aktion"])
arg = next(x for x in act["in"] if "enable" in x.lower())
on = str(params.get("enable")).lower() in ("1", "true", "an", "on", "ja")
return _fb_call(ip, user, pw, {"service": auth["short"], "action": act["name"],
"args": {arg: 1 if on else 0}, "confirmed": params.get("confirmed"),
"button_wait": params.get("button_wait")})
def _fb_backup_delete(params: dict) -> dict:
path = _fb_backup_path(params.get("file") or params.get("datei") or "")
if not path:
@@ -1763,6 +1845,10 @@ def _do_fritzbox(action: str, params: dict) -> dict:
return _fb_backup(ip, user, pw, params)
if action == "fritzbox.restore":
return _fb_restore(ip, user, pw, params)
if action == "fritzbox.second_factor":
return _fb_second_factor_info(ip, user, pw)
if action == "fritzbox.second_factor_set":
return _fb_second_factor_set(ip, user, pw, params)
if action == "fritzbox.hosts":
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
@@ -2679,7 +2765,7 @@ class Satellite:
# (dial.launch loest ein Geraet auf, oder es wurde ein device-Ref
# mitgegeben). http.get/http.post/wol arbeiten direkt mit url/mac —
# ein voller LAN-Scan davor kostete nur unnoetig viele Sekunden.
needs_devices = (action == "dial.launch" or bool(params.get("device"))
needs_devices = (action in ("dial.launch", "fritzbox.backup_all") or bool(params.get("device"))
or (action.startswith("fritzbox.")
and not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", str(params.get("ip") or ""))))
devices = await self._scan() if needs_devices else self._devices