feat(satellite): FritzBox-Bestaetigung per Taste (2 min) + Sicherung in den Chat

Der Export der Einstellungen scheiterte mit "HTTP 500" — dahinter steckt
UPnP-Fehler 866: die Box will die Aktion zusaetzlich am Geraet bestaetigt.

- _tr064 meldet jetzt den UPnP-Fehlercode statt nur "HTTP 500".
- 866 → ohne button_wait sofort zurueck mit needs_button (ARIA fragt Stefan,
  ob er an der Box steht). Mit button_wait=true: X_AVM-DE_Auth SetConfig
  (start), GetState alle 2 s bis FB_BUTTON_WAIT_SEC (120 s); "authenticated"
  → Aktion erneut, sonst Timeout "Taste nicht gedrueckt, nichts ausgefuehrt"
  (Bestaetigung wird gestoppt).
- fritzbox.backup: X_AVM-DE_GetConfigFile mit Kennwort (Standard: Login-
  Kennwort), Download mit Digest-Auth, Kopie auf dem Satelliten (die
  letzten 10 je Box) und Datei zurueck an ARIA.
- Brain: Datei aus dem Ergebnis landet in /shared/uploads/, ARIA legt sie
  mit [FILE: …] in den Chat; Timeout 200 s bei button_wait; Beschreibung
  erklaert den Tasten-Ablauf.

Getestet gegen nachgebaute Box: ohne Bestaetigung → needs_button, Taste
nach 3 s → Sicherung geladen, keine Taste → Timeout. Gegen eine echte Box
(X_AVM-DE_Auth-Ablauf) noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:40:42 +01:00
co-authored by Claude Opus 5.5
parent a58756ddfd
commit e91435a9f6
2 changed files with 134 additions and 2 deletions
+22 -1
View File
@@ -1307,7 +1307,15 @@ META_TOOLS = [
"setzt Du NUR, nachdem Du Stefan gesagt hast, was genau passiert, und er "
"ausdruecklich ja gesagt hat. Ohne confirmed kommt needs_confirmation "
"zurueck: dann fragen, nicht raten. [GEFAEHRLICH] (Neustart, "
"Werkseinstellung, Portfreigabe, Passwort) besonders deutlich ankuendigen. 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
"Werkseinstellung, Portfreigabe, Passwort) besonders deutlich ankuendigen. "
"TASTE AN DER BOX: Manche Aktionen (z.B. Sicherung) muss man an der Box "
"bestaetigen — dann kommt 'muss an der FritzBox bestaetigt werden'. Frag "
"Stefan, ob er an der Box steht; nach seinem Ja erneut mit button_wait=true "
"aufrufen, dann hat er 2 Minuten fuer einen Tastendruck (sonst Timeout, "
"nichts passiert — einfach nachfragen und neu versuchen). 'fritzbox.backup' "
"(params={'ip':..,'password':'<Kennwort der Sicherung>','button_wait':true}) "
"sichert die Einstellungen; die Datei liegt danach unter /shared/uploads/ "
"und kommt mit [FILE: <pfad>] in den Chat. 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
@@ -3246,6 +3254,8 @@ class Agent:
# Installation koennen zusammen mehrere Minuten dauern.
sat_timeout = (320.0 if action.startswith("adb.")
else 200.0 if action == "ssh.exec"
# button_wait: bis 2 min auf die Taste an der FritzBox
else 200.0 if action.startswith("fritzbox.") and params.get("button_wait")
else 60.0 if action.startswith("fritzbox.") else 20.0)
result = _post("/internal/satellite", {
"op": "command", "satellite": sat,
@@ -3254,7 +3264,18 @@ class Agent:
}, sat_timeout + 20)
if result.get("ok"):
r = result.get("result")
if isinstance(r, dict) and r.get("file_b64"):
# Datei vom Satelliten (z.B. FritzBox-Sicherung) → /shared/uploads/
import base64
fname = re.sub(r"[^A-Za-z0-9._-]", "_", str(r.get("filename") or "satellit_datei"))
path = f"/shared/uploads/sat_{int(time.time())}_{fname}"
with open(path, "wb") as fh:
fh.write(base64.b64decode(r.pop("file_b64")))
r["im_chat_ablegbar"] = path
r["hinweis_chat"] = f"In den Chat legen mit [FILE: {path}]"
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
if result.get("needs_button") or result.get("needs_confirmation") or result.get("timeout"):
return f"FEHLER: {result.get('error')}"
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
+112 -1
View File
@@ -860,7 +860,10 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
# Geraetename ("Fritzbox Keller") → IP, wie bei den anderen Aktionen
t = _snmp_target(params)
ip = _resolve_target(t, devices) or t
return await loop.run_in_executor(None, _do_fritzbox, action, {**params, "ip": ip})
extra = {"ip": ip}
if t and t != ip and not params.get("name"):
extra["name"] = t # z.B. Dateiname der Sicherung: fritzbox-keller_…
return await loop.run_in_executor(None, _do_fritzbox, action, {**params, **extra})
if action == "ssh.exec":
return await loop.run_in_executor(None, _do_ssh, params)
if action == "adb.devices":
@@ -1212,6 +1215,11 @@ def _tr064(ip: str, user: str, pw: str, service: str, control: str,
if r.status_code == 401:
return {"ok": False, "error": "TR-064 Auth fehlgeschlagen (FritzBox-Login pruefen)."}
if r.status_code != 200:
code = re.search(r"<errorCode>(\d+)</errorCode>", r.text or "")
desc = re.search(r"<errorDescription>(.*?)</errorDescription>", r.text or "", re.DOTALL)
if code:
return {"ok": False, "upnp_error": code.group(1),
"error": f"FritzBox lehnt ab: {code.group(1)} {(desc.group(1).strip() if desc else '')}".strip()}
return {"ok": False, "error": f"TR-064 HTTP {r.status_code}"}
fields = {m.group(1): m.group(2) for m in
re.finditer(r"<(New[^>/]+)>(.*?)</\1>", r.text, re.DOTALL)}
@@ -1327,6 +1335,42 @@ def _fb_describe(ip: str, params: dict) -> dict:
+ (f" Gekuerzt auf {limit} — mit 'filter' eingrenzen (z.B. 'WLAN', 'Dect', 'Hosts')." if len(lines) > limit else ""))}}
# Manche Aktionen (z.B. Einstellungen sichern) verlangt die Box zusaetzlich am
# Geraet bestaetigt: Fehler 866 = "zweiter Faktor noetig". Ablauf laut AVM:
# X_AVM-DE_Auth SetConfig(start) → jemand drueckt eine Taste an der Box (oder
# waehlt den DTMF-Code am Telefon) → GetState meldet "authenticated" → Aktion
# erneut ausfuehren.
FB_BUTTON_WAIT_SEC = float(os.environ.get("FB_BUTTON_WAIT_SEC", "120") or "120")
_FB_2FA_CODES = ("866",)
def _fb_second_factor(ip: str, user: str, pw: str) -> dict:
"""Startet die Bestaetigung am Geraet und wartet bis FB_BUTTON_WAIT_SEC."""
auth = _fb_find_service(ip, "X_AVM-DE_Auth")
if not auth:
return {"ok": False, "error": "Die Box bietet keine Bestaetigung per TR-064 an (X_AVM-DE_Auth fehlt)."}
st = _tr064(ip, user, pw, auth["type"], auth["control"], "SetConfig", {"NewAction": "start"})
if not st.get("ok"):
return {"ok": False, "error": f"Bestaetigung nicht startbar: {st.get('error')}"}
methods = st["fields"].get("NewMethods", "")
logger.info("[fritzbox] %s: warte bis %.0fs auf Bestaetigung am Geraet (%s)", ip, FB_BUTTON_WAIT_SEC, methods)
deadline = time.time() + FB_BUTTON_WAIT_SEC
state = st["fields"].get("NewState", "")
while time.time() < deadline:
if state == "authenticated":
return {"ok": True}
if state in ("blocked", "failure", "stopped"):
return {"ok": False, "error": f"Bestaetigung abgebrochen (Box meldet '{state}')."}
time.sleep(2)
g = _tr064(ip, user, pw, auth["type"], auth["control"], "GetState")
state = g.get("fields", {}).get("NewState", state) if g.get("ok") else state
_tr064(ip, user, pw, auth["type"], auth["control"], "SetConfig", {"NewAction": "stop"})
return {"ok": False, "timeout": True,
"error": f"Timeout: Die Taste an der Box wurde nicht innerhalb von {FB_BUTTON_WAIT_SEC:.0f} s "
f"gedrueckt — abgebrochen, nichts ausgefuehrt."
+ (f" (Moeglich waeren: {methods})" if methods else "")}
def _fb_call(ip: str, user: str, pw: str, params: dict) -> dict:
action = str(params.get("action") or "").strip()
svc = _fb_find_service(ip, str(params.get("service") or ""))
@@ -1344,11 +1388,76 @@ def _fb_call(ip: str, user: str, pw: str, params: dict) -> dict:
args = {(k if k.startswith("New") else "New" + k): v for k, v in args.items()}
logger.info("[fritzbox] %s %s %s args=%s", ip, svc["short"], action, list(args))
r = _tr064(ip, user, pw, svc["type"], svc["control"], action, args)
if not r.get("ok") and r.get("upnp_error") in _FB_2FA_CODES:
if params.get("button_wait") not in (True, "true"):
return {"ok": False, "needs_button": True, "error": (
f"'{svc['short']} {action}' muss an der FritzBox bestaetigt werden (Taste druecken). "
"Frag Stefan, ob er jetzt an der Box steht. Erst nach seinem Ja erneut mit "
f"button_wait=true aufrufen — ab dann hat er {FB_BUTTON_WAIT_SEC:.0f} s Zeit, eine Taste "
"an der Box zu druecken.")}
sf = _fb_second_factor(ip, user, pw)
if not sf.get("ok"):
return sf
r = _tr064(ip, user, pw, svc["type"], svc["control"], action, args)
if not r.get("ok"):
return r
return {"ok": True, "result": {"ip": ip, "service": svc["short"], "action": action, **r["fields"]}}
FB_BACKUP_DIR = os.environ.get("FB_BACKUP_DIR", "/data/fritzbox-backups")
FB_BACKUP_KEEP = int(os.environ.get("FB_BACKUP_KEEP", "10") or "10")
def _fb_backup(ip: str, user: str, pw: str, params: dict) -> dict:
"""Einstellungen der Box sichern (.export) — landet auf dem Satelliten unter
FB_BACKUP_DIR, die letzten FB_BACKUP_KEEP je Box bleiben. Kennwort der
Sicherung = params.password, sonst das FritzBox-Login (zum Wiederherstellen
noetig). Braucht meist die Taste an der Box (button_wait)."""
import requests
from requests.auth import HTTPDigestAuth
export_pw = str(params.get("password") or pw)
r = _fb_call(ip, user, pw, {"service": "DeviceConfig", "action": "X_AVM-DE_GetConfigFile",
"args": {"X_AVM-DE_Password": export_pw},
"button_wait": params.get("button_wait")})
if not r.get("ok"):
return r
url = r["result"].get("NewX_AVM-DE_ConfigFileUrl", "")
if not url:
return {"ok": False, "error": "Die Box lieferte keine Download-Adresse fuer die Sicherung."}
try:
d = requests.get(url, auth=HTTPDigestAuth(user, pw), timeout=60, verify=False)
d.raise_for_status()
except Exception as exc:
return {"ok": False, "error": f"Sicherung nicht herunterladbar: {exc}"}
os.makedirs(FB_BACKUP_DIR, exist_ok=True)
label = _slug(str(params.get("name") or ip))
fname = f"{label}_{time.strftime('%Y-%m-%d_%H%M')}.export"
path = os.path.join(FB_BACKUP_DIR, fname)
with open(path, "wb") as fh:
fh.write(d.content)
try:
os.chmod(path, 0o600)
except OSError:
pass
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
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 ""
return {"ok": True, "result": {
"ip": ip, "datei": path, "bytes": len(d.content), "auf": "Satellit",
**({"file_b64": file_b64, "filename": fname} if file_b64 else {}),
"kennwort": "das angegebene Kennwort" if params.get("password") else "das FritzBox-Login-Kennwort",
"hinweis": f"Liegt auf dem Satelliten, die letzten {FB_BACKUP_KEEP} je Box bleiben. "
"Zum Wiederherstellen in der FritzBox: System → Sicherung → Wiederherstellen, mit dem Kennwort."}}
def _fb_wlan(ip: str, user: str, pw: str, params: dict) -> dict:
"""Alle WLANs der Box (2,4/5/6 GHz, Gast) mit SSID und an/aus; mit
index + enable schalten (braucht confirmed=true)."""
@@ -1399,6 +1508,8 @@ def _do_fritzbox(action: str, params: dict) -> dict:
return _fb_call(ip, user, pw, params)
if action == "fritzbox.wlan":
return _fb_wlan(ip, user, pw, params)
if action == "fritzbox.backup":
return _fb_backup(ip, user, pw, params)
if action == "fritzbox.hosts":
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",