diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 2447cda..2226530 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1316,7 +1316,16 @@ META_TOOLS = [ "(params={'ip':..,'button_wait':true}) sichert die Einstellungen — OHNE " "'password': der Satellit nimmt das hinterlegte Sicherungs-Kennwort (das " "Du nicht kennst und nicht kennen sollst); 'password' nur, wenn Stefan " - "ausdruecklich ein anderes will. die Datei liegt danach unter /shared/uploads/ " + "ausdruecklich ein anderes will. SICHERUNGEN VERWALTEN: 'fritzbox.backups' " + "(params={'ip':..}) listet die Sicherungen der Box (datei, erstellt, modell, " + "firmware), neueste zuerst. 'fritzbox.backup_view' (params={'ip':..,'file':" + "''}) zeigt die wichtigsten Einstellungen daraus (Kennwoerter bleiben " + "verschluesselt). 'fritzbox.restore' (params={'ip':..,'file':''," + "'confirmed':true,'button_wait':true}) spielt eine Sicherung ein — GEFAEHRLICH: " + "Box uebernimmt alles und startet neu (1–3 min kein Netz); vorher Datum der " + "Sicherung nennen und Stefans ausdrueckliches Ja holen, ebenso ob er an der " + "Box steht. 'fritzbox.backup_delete' (params={'ip':..,'file':..,'confirmed':true}) " + "loescht eine Sicherung (nach Ja). die Datei liegt danach unter /shared/uploads/ " "und kommt mit [FILE: ] 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) + " diff --git a/diagnostic/index.html b/diagnostic/index.html index 1a38ad5..d22db5f 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -6073,6 +6073,7 @@ requestId: apkRequestId, params: { ip: apkTarget.ip } }); } function onSatResult(p) { + if (p && p.requestId && _fbPending[p.requestId]) { const r = _fbPending[p.requestId]; delete _fbPending[p.requestId]; r(p); return; } if (p && dcFbReq && p.requestId === dcFbReq) { dcOnFritzUsers(p); return; } if (!apkRequestId || !p || p.requestId !== apkRequestId) return; const status = document.getElementById('apk-status'); @@ -6271,6 +6272,11 @@ 'style="padding:1px 6px;font-size:11px;margin-left:8px;">' + (hasCreds ? '🔑 ✓' : '🔑') + '' : ''; // APK ausrollen: bei ADB-faehigen Geraeten (Fire TV & Co.). const canAdb = d.ip && (d.adbCapable || ['firetv', 'android', 'android-adb', 'androidtv', 'dial'].includes(d.type)); + // FritzBox: Sicherungen (erstellen, ansehen, wiederherstellen) + const isFritzDev = d.ip && /fritz/i.test([d.name, d.model, d.type, d.service].join(' ')); + const fbBtn = isFritzDev ? '' : ''; const apkBtn = canAdb ? '' : ''; @@ -6289,7 +6295,7 @@ '' + escapeHtml(d.name || d.hostname || d.id || '?') + ' ' + '[' + escapeHtml(d.type || '?') + '] ' + '' + escapeHtml(d.ip || '') + '' + tagStr + - '' + nameBtn + keyBtn + apkBtn + + '' + nameBtn + keyBtn + fbBtn + apkBtn + ''; }).join('') + ''; } @@ -6315,6 +6321,149 @@ }).join(''); } + // ── FritzBox-Sicherungen ─────────────────────────────── + // Liste je Box, ℹ = Inhalt lesbar, Wiederherstellen, Loeschen, Jetzt sichern. + // Sichern/Wiederherstellen verlangt die Box oft per Taste bestaetigt: der + // Satellit wartet dann bis 2 Minuten, hier laeuft ein Countdown. + const _fbPending = {}; + let fbB = null; + function fbCmd(cmd, params, timeoutMs = 30000) { + const requestId = 'fbb_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); + return new Promise(resolve => { + const t = setTimeout(() => { delete _fbPending[requestId]; resolve({ ok: false, error: 'Keine Antwort vom Satelliten.' }); }, timeoutMs); + _fbPending[requestId] = p => { clearTimeout(t); resolve(p); }; + send({ action: 'sat_command', satellite: fbB.sat, cmd, requestId, params: { ip: fbB.ip, name: fbB.name, ...params } }); + }); + } + function _fbModal(html) { + let m = document.getElementById('fb-backups-modal'); + if (!m) { + m = document.createElement('div'); + m.id = 'fb-backups-modal'; + m.style.cssText = 'position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;'; + m.addEventListener('click', e => { if (e.target === m) closeFbBackups(); }); + document.body.appendChild(m); + } + m.innerHTML = `
${html}
`; + return m; + } + function closeFbBackups() { const m = document.getElementById('fb-backups-modal'); if (m) m.remove(); fbB = null; } + const _fbFmtDate = iso => { try { return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); } catch (_) { return iso; } }; + const _fbPwSrc = { backupPass: 'Sicherungs-Kennwort', login: 'FritzBox-Kennwort', angegeben: 'eigenes Kennwort' }; + async function openFbBackups(sat, ip, name) { + fbB = { sat, ip, name, list: [], busy: '' }; + _fbRender('Lade Sicherungen …'); + const r = await fbCmd('fritzbox.backups', {}); + if (!fbB) return; + fbB.list = (r.ok && r.result && r.result.sicherungen) || []; + _fbRender(r.ok ? '' : '⚠ ' + (r.error || 'Fehler')); + } + function _fbRender(status) { + if (!fbB) return; + const rows = fbB.list.map((b, i) => ` + ${escapeHtml(_fbFmtDate(b.erstellt) || b.datei)} + ${escapeHtml(b.modell || '')}${b.firmware ? ' · FW ' + escapeHtml(b.firmware) : ''} + ${_fmtBytes(b.bytes)} + ${escapeHtml(_fbPwSrc[b.kennwort] || b.kennwort || '')} + + + + `).join(''); + _fbModal(`
+

💾 Sicherungen: ${escapeHtml(fbB.name)} (${escapeHtml(fbB.ip)})

+
+

Liegen auf dem Satelliten, die letzten 10 je Box. Verschlüsselt mit dem hinterlegten Sicherungs-Kennwort (🔑).

+ +
+ ${fbB.list.length ? `${rows}
` + : '
Noch keine Sicherung.
'} +
${escapeHtml(status || '')}
`); + } + // Lauf mit Countdown — falls die Box eine Bestaetigung per Taste will + async function _fbRun(label, cmd, params) { + fbB.busy = label; + _fbRender(''); + const box = document.getElementById('fb-run'); + const end = Date.now() + 120000; + const tick = () => { + const el = document.getElementById('fb-run'); + if (!el || !fbB || !fbB.busy) return; + const left = Math.max(0, Math.round((end - Date.now()) / 1000)); + el.innerHTML = `${escapeHtml(label)} …
Falls die Box eine Bestätigung verlangt: jetzt eine Taste an der FritzBox drücken — noch ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}
`; + }; + tick(); + const iv = setInterval(tick, 1000); + const r = await fbCmd(cmd, { ...params, button_wait: true, no_file: true }, 170000); + clearInterval(iv); + if (!fbB) return r; + fbB.busy = ''; + return r; + } + function _fbShowResult(r, okText) { + _fbRender(''); + const el = document.getElementById('fb-run'); + el.style.display = ''; + if (r.ok) { + el.innerHTML = `✓ ${escapeHtml(okText)}` + (r.result && r.result.hinweis ? `
${escapeHtml(r.result.hinweis)}
` : ''); + setTimeout(() => { const e = document.getElementById('fb-run'); if (e && fbB && !fbB.busy) e.style.display = 'none'; }, 6000); + } else { + el.innerHTML = `⚠ ${r.timeout ? 'Taste nicht gedrückt' : 'Nicht geklappt'}
${escapeHtml(r.error || 'unbekannter Fehler')}
`; + } + } + async function fbCreateBackup() { + if (!await uiConfirm(`Sicherung von ${fbB.name} erstellen?\n\nFalls die Box eine Bestätigung verlangt, hast du nach dem Start 2 Minuten, um eine Taste an der FritzBox zu drücken.`)) return; + const r = await _fbRun('Sicherung läuft', 'fritzbox.backup', {}); + if (!fbB) return; + const l = await fbCmd('fritzbox.backups', {}); + if (fbB && l.ok) fbB.list = l.result.sicherungen || []; + _fbShowResult(r, 'Sicherung erstellt'); + } + async function fbRestoreBackup(i) { + const b = fbB.list[i]; + if (!b) return; + if (!await uiConfirm(`${fbB.name} auf die Sicherung vom ${_fbFmtDate(b.erstellt)} zurücksetzen?\n\nDie Box übernimmt ALLE Einstellungen dieser Sicherung und startet neu — 1–3 Minuten kein Netz und kein Internet. Falls die Box eine Bestätigung verlangt, hast du nach dem Start 2 Minuten für den Tastendruck.`)) return; + const r = await _fbRun('Wiederherstellen läuft', 'fritzbox.restore', { file: b.datei, confirmed: true }); + if (fbB) _fbShowResult(r, 'Sicherung eingespielt'); + } + async function fbDeleteBackup(i) { + const b = fbB.list[i]; + if (!b || !await uiConfirm(`Sicherung vom ${_fbFmtDate(b.erstellt)} löschen?\n\nSie ist danach endgültig weg.`)) return; + const r = await fbCmd('fritzbox.backup_delete', { file: b.datei, confirmed: true }); + if (!fbB) return; + if (r.ok) fbB.list.splice(i, 1); + _fbRender(r.ok ? 'Gelöscht.' : '⚠ ' + (r.error || 'Fehler')); + } + async function fbViewBackup(i) { + const b = fbB.list[i]; + if (!b) return; + _fbRender('Lese Sicherung …'); + const r = await fbCmd('fritzbox.backup_view', { file: b.datei, full: true }, 60000); + if (!fbB) return; + if (!r.ok) { _fbRender('⚠ ' + (r.error || 'Fehler')); return; } + const v = r.result || {}; + const kopf = Object.entries(v.kopf || {}).map(([k, x]) => `${escapeHtml(k)}${escapeHtml(x)}`).join(''); + const wichtig = (v.wichtig || []).map(w => `${escapeHtml(w.was)}${escapeHtml(w.wert)}${escapeHtml(w.datei)}`).join(''); + const secs = Object.entries(v.inhalt || {}).map(([n, t]) => `
${escapeHtml(n)} (${_fmtBytes(t.length)}) +
${escapeHtml(t)}
`).join(''); + _fbModal(`
+

ℹ Sicherung vom ${escapeHtml(_fbFmtDate(b.erstellt))}

+
+

Verschlüsselte Werte (Kennwörter, Schlüssel) stehen als «verschluesselt» da.

+ ${kopf ? `${kopf}
` : ''} + ${wichtig ? `

Wichtigste Einstellungen

${wichtig}
` : ''} +

Alle Bereiche

+ +
${secs || 'Keine lesbaren Bereiche.'}
`); + } + function fbFilterView(q) { + q = String(q || '').toLowerCase(); + document.querySelectorAll('#fb-view-secs details').forEach(d => { + const hit = !q || d.dataset.text.includes(q); + d.style.display = hit ? '' : 'none'; + d.open = !!q && hit; + }); + } + // ── Geraete-Credentials ──────────────────────────────── function dcToggleV3() { const v = document.getElementById('dc-snmp-version').value; diff --git a/satellite/.env.example b/satellite/.env.example index 0f818ac..0a2fae6 100644 --- a/satellite/.env.example +++ b/satellite/.env.example @@ -43,6 +43,10 @@ CONTROL_ENABLED=true # fritzbox.describe FritzBox: was DIESE Box kann (alle TR-064-Aktionen, ohne Login) # fritzbox.call FritzBox: beliebige TR-064-Aktion; aendernde nur mit confirmed=true # fritzbox.wlan FritzBox: alle WLANs (inkl. Gast) anzeigen / schalten +# fritzbox.backup / backups / backup_view / restore / backup_delete +# FritzBox: Einstellungen sichern (Taste an der Box, 2 min), +# auflisten, lesbar ansehen, wieder einspielen, loeschen. +# Sicherungen liegen unter FB_BACKUP_DIR (/data/fritzbox-backups). # (fritzbox.* in der Liste erlaubt alle fritzbox-Aktionen) # ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh') # adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url) diff --git a/satellite/satellite.py b/satellite/satellite.py index fd85d32..354aa81 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -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):