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:
+22
-1
@@ -1307,7 +1307,15 @@ META_TOOLS = [
|
|||||||
"setzt Du NUR, nachdem Du Stefan gesagt hast, was genau passiert, und er "
|
"setzt Du NUR, nachdem Du Stefan gesagt hast, was genau passiert, und er "
|
||||||
"ausdruecklich ja gesagt hat. Ohne confirmed kommt needs_confirmation "
|
"ausdruecklich ja gesagt hat. Ohne confirmed kommt needs_confirmation "
|
||||||
"zurueck: dann fragen, nicht raten. [GEFAEHRLICH] (Neustart, "
|
"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) "
|
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
|
||||||
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
|
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
|
||||||
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
|
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
|
||||||
@@ -3246,6 +3254,8 @@ class Agent:
|
|||||||
# Installation koennen zusammen mehrere Minuten dauern.
|
# Installation koennen zusammen mehrere Minuten dauern.
|
||||||
sat_timeout = (320.0 if action.startswith("adb.")
|
sat_timeout = (320.0 if action.startswith("adb.")
|
||||||
else 200.0 if action == "ssh.exec"
|
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)
|
else 60.0 if action.startswith("fritzbox.") else 20.0)
|
||||||
result = _post("/internal/satellite", {
|
result = _post("/internal/satellite", {
|
||||||
"op": "command", "satellite": sat,
|
"op": "command", "satellite": sat,
|
||||||
@@ -3254,7 +3264,18 @@ class Agent:
|
|||||||
}, sat_timeout + 20)
|
}, sat_timeout + 20)
|
||||||
if result.get("ok"):
|
if result.get("ok"):
|
||||||
r = result.get("result")
|
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."
|
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'}"
|
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
||||||
|
|||||||
+112
-1
@@ -860,7 +860,10 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
|||||||
# Geraetename ("Fritzbox Keller") → IP, wie bei den anderen Aktionen
|
# Geraetename ("Fritzbox Keller") → IP, wie bei den anderen Aktionen
|
||||||
t = _snmp_target(params)
|
t = _snmp_target(params)
|
||||||
ip = _resolve_target(t, devices) or t
|
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":
|
if action == "ssh.exec":
|
||||||
return await loop.run_in_executor(None, _do_ssh, params)
|
return await loop.run_in_executor(None, _do_ssh, params)
|
||||||
if action == "adb.devices":
|
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:
|
if r.status_code == 401:
|
||||||
return {"ok": False, "error": "TR-064 Auth fehlgeschlagen (FritzBox-Login pruefen)."}
|
return {"ok": False, "error": "TR-064 Auth fehlgeschlagen (FritzBox-Login pruefen)."}
|
||||||
if r.status_code != 200:
|
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}"}
|
return {"ok": False, "error": f"TR-064 HTTP {r.status_code}"}
|
||||||
fields = {m.group(1): m.group(2) for m in
|
fields = {m.group(1): m.group(2) for m in
|
||||||
re.finditer(r"<(New[^>/]+)>(.*?)</\1>", r.text, re.DOTALL)}
|
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 ""))}}
|
+ (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:
|
def _fb_call(ip: str, user: str, pw: str, params: dict) -> dict:
|
||||||
action = str(params.get("action") or "").strip()
|
action = str(params.get("action") or "").strip()
|
||||||
svc = _fb_find_service(ip, str(params.get("service") or ""))
|
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()}
|
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))
|
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)
|
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"):
|
if not r.get("ok"):
|
||||||
return r
|
return r
|
||||||
return {"ok": True, "result": {"ip": ip, "service": svc["short"], "action": action, **r["fields"]}}
|
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:
|
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
|
"""Alle WLANs der Box (2,4/5/6 GHz, Gast) mit SSID und an/aus; mit
|
||||||
index + enable schalten (braucht confirmed=true)."""
|
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)
|
return _fb_call(ip, user, pw, params)
|
||||||
if action == "fritzbox.wlan":
|
if action == "fritzbox.wlan":
|
||||||
return _fb_wlan(ip, user, pw, params)
|
return _fb_wlan(ip, user, pw, params)
|
||||||
|
if action == "fritzbox.backup":
|
||||||
|
return _fb_backup(ip, user, pw, params)
|
||||||
|
|
||||||
if action == "fritzbox.hosts":
|
if action == "fritzbox.hosts":
|
||||||
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
|
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
|
||||||
|
|||||||
Reference in New Issue
Block a user