feat: FritzBox-Sicherungen verwalten — Liste, Ansicht, Wiederherstellen, Loeschen

Satellit:
- sat_command laeuft als eigener Task (eine Aktion, die 2 min auf die
  Taste wartet, blockiert den Satelliten nicht mehr).
- Sicherung bekommt eine Begleitdatei (Box, Modell, Firmware, Seriennr.,
  Datum, QUELLE des Kennworts — nie der Wert).
- fritzbox.backups: Sicherungen je Box, neueste zuerst.
- fritzbox.backup_view: lesbar — Kopfdaten, wichtigste Einstellungen
  (Name, LAN/DHCP, WLAN, Kanal, DNS, SIP …), mit full alle Bereiche;
  verschluesselte Werte als «verschluesselt»; nur Dateien aus dem
  Sicherungsordner (kein Pfad von aussen).
- fritzbox.restore: X_AVM-DE_SetConfigFile — der Satellit stellt die Datei
  kurz per HTTP bereit (Zufallspfad, endet nach Abruf/10 min), die Box holt
  sie ab und startet neu. Nur mit confirmed, Taste per button_wait, mit dem
  Kennwort aus der Quelle der Sicherung; fremde Box nur mit other_box.
- fritzbox.backup_delete (mit confirmed). no_file fuer das Diagnostic.

Diagnostic: 💾 bei FritzBox-Geraeten → Sicherungen: Jetzt sichern,
ℹ (lesbar + Suche), Wiederherstellen, Loeschen; Sichern/Wiederherstellen
mit 2-Minuten-Countdown fuer die Taste, Ergebnis im Fenster (✓ / Taste
nicht gedrueckt / Grund), Erfolg blendet sich aus.

Brain: Beschreibung fuer Liste/Ansicht/Wiederherstellen/Loeschen.

Getestet: Satellit gegen nachgebaute Box (Sichern, Liste, Ansicht ohne
Geheimnisse, Pfadschutz, Wiederherstellen inkl. Abholen der Datei,
Loeschen), Diagnostic-Fenster in headless Chromium mit simuliertem
Satelliten. Gegen eine echte Box noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:52:04 +01:00
co-authored by Claude Opus 5.5
parent 5af03eedb9
commit 2bfa064ef6
4 changed files with 399 additions and 26 deletions
+235 -24
View File
@@ -35,6 +35,7 @@ import os
import re
import socket
import struct
import threading
import time
from pathlib import Path
from typing import Optional
@@ -1484,17 +1485,29 @@ def _fb_backup(ip: str, user: str, pw: str, params: dict) -> dict:
os.chmod(path, 0o600)
except OSError:
pass
info = _tr064(ip, user, pw, "urn:dslforum-org:service:DeviceInfo:1", "/upnp/control/deviceinfo", "GetInfo")
fi = info.get("fields", {}) if info.get("ok") else {}
meta = {"ip": ip, "name": str(params.get("name") or ip), "created": time.strftime("%Y-%m-%dT%H:%M:%S"),
"bytes": len(d.content), "model": fi.get("NewModelName", ""), "firmware": fi.get("NewSoftwareVersion", ""),
"serial": fi.get("NewSerialNumber", ""),
# nur die QUELLE des Kennworts — zum Wiederherstellen noetig, nie der Wert
"kennwort": "angegeben" if params.get("password") else ("backupPass" if stored else "login")}
with open(path[:-len(".export")] + ".json", "w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=1)
old = sorted(f for f in os.listdir(FB_BACKUP_DIR) if f.startswith(label + "_") and f.endswith(".export"))
for f in old[:-FB_BACKUP_KEEP]:
try:
os.remove(os.path.join(FB_BACKUP_DIR, f))
except OSError:
pass
for ext in (".export", ".json"):
try:
os.remove(os.path.join(FB_BACKUP_DIR, f[:-len(".export")] + ext))
except OSError:
pass
logger.info("[fritzbox] %s: Sicherung %s (%d Bytes)", ip, fname, len(d.content))
import base64
# Die Datei geht mit zurueck — ARIAs Brain legt sie unter /shared/uploads/
# ab, damit sie in den Chat kann. Sie ist mit dem Kennwort verschluesselt.
file_b64 = base64.b64encode(d.content).decode() if len(d.content) <= 20 * 1024 * 1024 else ""
# no_file: das Diagnostic braucht nur die Meldung, die Datei bleibt auf dem Satelliten
file_b64 = (base64.b64encode(d.content).decode()
if len(d.content) <= 20 * 1024 * 1024 and not params.get("no_file") else "")
return {"ok": True, "result": {
"ip": ip, "datei": path, "bytes": len(d.content), "auf": "Satellit",
**({"file_b64": file_b64, "filename": fname} if file_b64 else {}),
@@ -1533,6 +1546,191 @@ def _fb_wlan(ip: str, user: str, pw: str, params: dict) -> dict:
"hinweis": "Das letzte WLAN ist meist das Gast-WLAN. Schalten: index + enable (+ confirmed=true nach Stefans Ja)."}}
# ─── Sicherungen verwalten: Liste, Ansicht, Wiederherstellen, Loeschen ───
def _fb_backup_path(name: str) -> str:
"""Nur Dateien aus FB_BACKUP_DIR — kein Pfad von aussen."""
base = os.path.basename(str(name or ""))
if not base.endswith(".export"):
base += ".export"
path = os.path.join(FB_BACKUP_DIR, base)
return path if os.path.isfile(path) else ""
def _fb_backup_meta(path: str) -> dict:
try:
with open(path[:-len(".export")] + ".json", encoding="utf-8") as fh:
return json.load(fh)
except Exception:
return {}
def _fb_backups(ip: str, params: dict) -> dict:
label = _slug(str(params.get("name") or ""))
out = []
for f in sorted(os.listdir(FB_BACKUP_DIR) if os.path.isdir(FB_BACKUP_DIR) else [], reverse=True):
if not f.endswith(".export"):
continue
m = _fb_backup_meta(os.path.join(FB_BACKUP_DIR, f))
if m.get("ip") != ip and not (label and f.startswith(label + "_")):
continue
out.append({"datei": f, "erstellt": m.get("created", ""), "bytes": m.get("bytes") or
os.path.getsize(os.path.join(FB_BACKUP_DIR, f)), "modell": m.get("model", ""),
"firmware": m.get("firmware", ""), "kennwort": m.get("kennwort", "")})
return {"ok": True, "result": {"ip": ip, "count": len(out), "sicherungen": out}}
# Lesbare Hinweise aus der Sicherung: AVM-Konfig "schluessel = wert;"
_FB_VIEW_KEYS = {
"hostname": "Name der Box", "ssid": "WLAN-Name (2,4 GHz)", "ssid_scnd": "WLAN-Name (5 GHz)",
"guest_ssid": "Gast-WLAN-Name", "channel": "WLAN-Kanal", "ipaddr": "IP-Adresse",
"netmask": "Netzmaske", "dhcpstart": "DHCP von", "dhcpend": "DHCP bis",
"username": "Benutzername", "registrar": "SIP-Registrar", "msn": "Rufnummer",
"dnsserver1": "DNS-Server 1", "dnsserver2": "DNS-Server 2", "ntp_server": "Zeitserver",
}
def _fb_backup_view(params: dict) -> dict:
path = _fb_backup_path(params.get("file") or params.get("datei") or "")
if not path:
return {"ok": False, "error": "Sicherung nicht gefunden (Dateiname aus fritzbox.backups)."}
text = open(path, encoding="utf-8", errors="replace").read()
header, sections, binfiles = {}, {}, []
cur, buf = None, []
for line in text.splitlines():
m = re.match(r"\*{4} (CFGFILE|BINFILE|B64FILE):(\S+)", line)
if m:
if cur:
sections[cur] = "\n".join(buf)
cur, buf = (m.group(2) if m.group(1) == "CFGFILE" else None), []
if m.group(1) != "CFGFILE":
binfiles.append(m.group(2))
continue
if line.startswith("**** END OF FILE"):
if cur:
sections[cur] = "\n".join(buf)
cur, buf = None, []
continue
if cur is not None:
buf.append(line)
elif not sections and not binfiles and "=" in line and not line.startswith("****"):
k, v = line.split("=", 1)
header[k.strip()] = "«verschluesselt»" if v.strip().startswith("$$$$") else v.strip()
secret = re.compile(r"\$\$\$\$\S+")
highlights = []
for name, body in sections.items():
for m in re.finditer(r"^\s*(\w+)\s*=\s*\"?([^;\"]*)\"?\s*;", body, re.MULTILINE):
key, val = m.group(1), m.group(2).strip()
if key in _FB_VIEW_KEYS and val and len(highlights) < 80:
highlights.append({"was": _FB_VIEW_KEYS[key], "wert": "«verschluesselt»" if val.startswith("$$$$") else val,
"datei": name, "schluessel": key})
res = {"datei": os.path.basename(path), "meta": _fb_backup_meta(path), "kopf": header,
"abschnitte": sorted(sections), "anhaenge": binfiles, "wichtig": highlights}
if params.get("full"):
budget, full = 900_000, {}
for name, body in sections.items():
body = secret.sub("«verschluesselt»", body)
full[name] = body[:max(0, budget)]
budget -= len(body)
res["inhalt"] = full
return {"ok": True, "result": res}
def _fb_serve_once(path: str, seconds: float = 600):
"""Stellt EINE Datei kurz per HTTP bereit (die Box holt sich die Sicherung
beim Wiederherstellen selbst ab). Zufaelliger Pfad, endet nach dem ersten
Abruf oder nach `seconds`. Gibt (url, served_event)."""
import http.server
import secrets as _secrets
token = _secrets.token_urlsafe(16)
fname = os.path.basename(path)
served = threading.Event()
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
if self.path != f"/{token}/{fname}":
self.send_response(404)
self.end_headers()
return
data = open(path, "rb").read()
self.send_response(200)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
served.set()
srv = http.server.ThreadingHTTPServer(("0.0.0.0", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
def _stop():
served.wait(seconds)
time.sleep(5)
srv.shutdown()
threading.Thread(target=_stop, daemon=True).start()
host = (NET or {}).get("primary_ip") or "127.0.0.1"
return f"http://{host}:{srv.server_address[1]}/{token}/{fname}", served
def _fb_restore(ip: str, user: str, pw: str, params: dict) -> dict:
path = _fb_backup_path(params.get("file") or params.get("datei") or "")
if not path:
return {"ok": False, "error": "Sicherung nicht gefunden (Dateiname aus fritzbox.backups)."}
meta = _fb_backup_meta(path)
if meta.get("ip") and meta["ip"] != ip and not params.get("other_box"):
return {"ok": False, "error": (f"Diese Sicherung stammt von {meta.get('name') or meta['ip']} ({meta.get('model', '?')}), "
f"nicht von {ip}. Auf eine andere Box nur mit other_box=true einspielen.")}
if params.get("confirmed") not in (True, "true"):
return {"ok": False, "needs_confirmation": True, "error": (
f"Wiederherstellen von {os.path.basename(path)} ist GEFAEHRLICH: die Box uebernimmt ALLE "
"Einstellungen dieser Sicherung und startet neu (1–3 Minuten kein Netz/Internet). Erst "
"Stefan fragen; nur nach seinem ausdruecklichen Ja erneut mit confirmed=true.")}
src = meta.get("kennwort", "")
stored = _creds_for(ip).get("fritzbox", {}).get("backupPass")
if params.get("password"):
export_pw = str(params["password"])
elif src == "angegeben":
return {"ok": False, "error": "Diese Sicherung wurde mit einem eigens genannten Kennwort erstellt — "
"Stefan muss es fuer das Wiederherstellen angeben (password)."}
elif src == "backupPass":
if not stored:
return {"ok": False, "error": "Fuer diese Sicherung ist kein Sicherungs-Kennwort mehr hinterlegt."}
export_pw = stored
else:
export_pw = pw
url, served = _fb_serve_once(path)
r = _fb_call(ip, user, pw, {"service": "DeviceConfig", "action": "X_AVM-DE_SetConfigFile",
"args": {"X_AVM-DE_Password": export_pw, "X_AVM-DE_ConfigFileUrl": url},
"confirmed": True, "button_wait": params.get("button_wait")})
if not r.get("ok"):
return r
got = served.wait(30)
logger.info("[fritzbox] %s: Wiederherstellen %s (abgeholt: %s)", ip, os.path.basename(path), got)
return {"ok": True, "result": {
"ip": ip, "datei": os.path.basename(path), "abgeholt": got,
"hinweis": ("Die Box hat die Sicherung abgeholt, uebernimmt die Einstellungen und startet neu — "
"1–3 Minuten nicht erreichbar." if got else
"Die Box hat den Auftrag angenommen, die Datei aber (noch) nicht abgeholt. Erreicht sie "
"den Satelliten? In ein paar Minuten pruefen, ob die Einstellungen uebernommen sind.")}}
def _fb_backup_delete(params: dict) -> dict:
path = _fb_backup_path(params.get("file") or params.get("datei") or "")
if not path:
return {"ok": False, "error": "Sicherung nicht gefunden."}
if params.get("confirmed") not in (True, "true"):
return {"ok": False, "needs_confirmation": True,
"error": f"{os.path.basename(path)} endgueltig loeschen? Erst Stefan fragen, dann confirmed=true."}
for ext in (".export", ".json"):
try:
os.remove(path[:-len(".export")] + ext)
except OSError:
pass
return {"ok": True, "result": {"geloescht": os.path.basename(path)}}
def _do_fritzbox(action: str, params: dict) -> dict:
"""fritzbox.info -> Modell/Firmware/Verbindung/externe IP/Datenrate.
fritzbox.hosts -> Liste der bekannten Geraete (Name/IP/MAC/aktiv) plus
@@ -1543,6 +1741,12 @@ def _do_fritzbox(action: str, params: dict) -> dict:
return {"ok": False, "error": "ip/host erforderlich."}
if action == "fritzbox.describe": # braucht keine Anmeldung
return _fb_describe(ip, params)
if action == "fritzbox.backups":
return _fb_backups(ip, params)
if action == "fritzbox.backup_view":
return _fb_backup_view(params)
if action == "fritzbox.backup_delete":
return _fb_backup_delete(params)
fb = _creds_for(ip).get("fritzbox", {})
user = str(params.get("user") or fb.get("user") or "")
pw = str(params.get("pass") or params.get("password") or fb.get("pass") or "")
@@ -1557,6 +1761,8 @@ def _do_fritzbox(action: str, params: dict) -> dict:
return _fb_wlan(ip, user, pw, params)
if action == "fritzbox.backup":
return _fb_backup(ip, user, pw, params)
if action == "fritzbox.restore":
return _fb_restore(ip, user, pw, params)
if action == "fritzbox.hosts":
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
@@ -2463,6 +2669,27 @@ class Satellite:
"timestamp": int(time.time() * 1000),
})
async def _command_reply(self, payload: dict) -> None:
req_id = payload.get("requestId", "")
action = (payload.get("action") or "").strip()
params = payload.get("params") or {}
if payload.get("device") and "device" not in params:
params["device"] = payload.get("device")
# Geraeteliste nur scannen, wenn die Aktion sie wirklich braucht
# (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"))
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
result = await _control(action, params, devices)
await self._send({
"type": "sat_result",
"payload": {"requestId": req_id, "satellite": SATELLITE_ID, **result},
"timestamp": int(time.time() * 1000),
})
async def _handle(self, raw: str) -> None:
try:
msg = json.loads(raw)
@@ -2525,25 +2752,9 @@ class Satellite:
elif mtype == "sat_command":
if not self._for_me(payload):
return
req_id = payload.get("requestId", "")
action = (payload.get("action") or "").strip()
params = payload.get("params") or {}
if payload.get("device") and "device" not in params:
params["device"] = payload.get("device")
# Geraeteliste nur scannen, wenn die Aktion sie wirklich braucht
# (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"))
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
result = await _control(action, params, devices)
await self._send({
"type": "sat_result",
"payload": {"requestId": req_id, "satellite": SATELLITE_ID, **result},
"timestamp": int(time.time() * 1000),
})
# Eigener Task: eine Aktion kann lange dauern (FritzBox wartet bis
# 2 min auf die Taste, adb.install Minuten) — der Loop bleibt frei.
asyncio.create_task(self._command_reply(payload))
elif mtype == "sat_creds_set":
if not self._for_me(payload):