diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 2226530..9b15c6c 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1325,7 +1325,15 @@ META_TOOLS = [ "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/ " + "loescht eine Sicherung (nach Ja). ALLE BOXEN SICHERN: 'fritzbox.backup_all' " + "(params={} oder {'button_wait':true}) — pro Satellit einmal aufrufen (jeder " + "Satellit sichert seine Boxen mit hinterlegtem Login); nicht erreichbare oder " + "fehlerhafte werden uebersprungen, es geht mit der naechsten weiter — am Ende " + "die Liste je Box (✓/✗ + Grund) an Stefan. TASTENBESTAETIGUNG: " + "'fritzbox.second_factor' zeigt, ob sie an ist und ob sie sich per Schnittstelle " + "schalten laesst; 'fritzbox.second_factor_set' (params={'ip':..,'enable':false," + "'confirmed':true,'button_wait':true}) schaltet sie — nur nach Stefans Ja; " + "geht es nicht, den Hinweis (Weg ueber die Oberflaeche) weitergeben. 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) + " @@ -3266,6 +3274,7 @@ class Agent: 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 900.0 if action == "fritzbox.backup_all" 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", { diff --git a/diagnostic/index.html b/diagnostic/index.html index d22db5f..ecef0f5 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -4487,7 +4487,7 @@ // uiConfirm → true/false, uiPrompt → Text oder null, uiAlert → undefined. // Erster Absatz der Meldung = Ueberschrift, der Rest = Erklaerung. // Esc / Klick daneben = Abbrechen, Enter = OK. - const _UI_DANGER = /l[öo]e?schen|entfernen|zur[üu]cksetzen|NOT-AUS|AGGRESSIV|WIRKLICH|endg[üu]ltig|archivieren|beenden|verloren|[üu]berschrieben|leeren/i; + const _UI_DANGER = /l[öo]e?schen|entfernen|zur[üu]cksetzen|NOT-AUS|AGGRESSIV|WIRKLICH|endg[üu]ltig|archivieren|beenden|verloren|[üu]berschrieben|leeren|ausschalten|deaktivier|zur[üu]cksetzen/i; function _uiDialog({ message, input, defaultValue, buttons }) { return new Promise(resolve => { const text = String(message ?? ''); @@ -6351,11 +6351,12 @@ 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: '' }; + fbB = { sat, ip, name, list: [], busy: '', sf: null }; _fbRender('Lade Sicherungen …'); - const r = await fbCmd('fritzbox.backups', {}); + const [r, sf] = await Promise.all([fbCmd('fritzbox.backups', {}), fbCmd('fritzbox.second_factor', {})]); if (!fbB) return; fbB.list = (r.ok && r.result && r.result.sicherungen) || []; + fbB.sf = sf.ok ? sf.result : { fehler: sf.error }; _fbRender(r.ok ? '' : '⚠ ' + (r.error || 'Fehler')); } function _fbRender(status) { @@ -6373,7 +6374,10 @@

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

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

- + ${_fbSfLine()} +
+ +
${fbB.list.length ? `${rows}
` : '
Noch keine Sicherung.
'} @@ -6393,7 +6397,8 @@ }; tick(); const iv = setInterval(tick, 1000); - const r = await fbCmd(cmd, { ...params, button_wait: true, no_file: true }, 170000); + // "alle sichern": pro Box bis 2 min Taste — insgesamt grosszuegig warten + const r = await fbCmd(cmd, { ...params, button_wait: true, no_file: true }, cmd === 'fritzbox.backup_all' ? 15 * 60000 : 170000); clearInterval(iv); if (!fbB) return r; fbB.busy = ''; @@ -6410,6 +6415,44 @@ el.innerHTML = `⚠ ${r.timeout ? 'Taste nicht gedrückt' : 'Nicht geklappt'}
${escapeHtml(r.error || 'unbekannter Fehler')}
`; } } + // Tastenbestaetigung der Box: Status + Umschalten (wenn die Firmware es kann) + function _fbSfLine() { + const sf = fbB.sf; + if (!sf) return '
Tastenbestätigung: wird geprüft …
'; + if (sf.fehler) return `
Tastenbestätigung: unbekannt (${escapeHtml(sf.fehler)})
`; + if (!sf.vorhanden) return '
Tastenbestätigung: gibt es bei dieser Box nicht — alles läuft ohne Taste.
'; + const st = sf.an === true ? 'an' : sf.an === false ? 'aus' : 'unbekannt'; + const btn = sf.an === null ? '' : ``; + return `
Tastenbestätigung: ${st}${btn}
`; + } + async function fbToggleSf(on) { + if (fbB.sf && !fbB.sf.schaltbar) { uiAlert('Lässt sich nicht umschalten\n\n' + (fbB.sf.hinweis || '')); return; } + if (!await uiConfirm(`Tastenbestätigung an ${fbB.name} ${on ? 'einschalten' : 'ausschalten'}?\n\n` + + (on ? 'Danach verlangt die Box für Sicherungen und heikle Einstellungen wieder einen Tastendruck.' + : 'Danach laufen Sicherungen ohne Tastendruck — auch „alle FritzBoxen sichern“. Wer das Login kennt, kann dann aber auch heikle Einstellungen ohne Bestätigung am Gerät ändern.') + + '\n\nDas Umschalten selbst muss die Box meist per Taste bestätigen (2 Minuten).')) return; + const r = await _fbRun(on ? 'Einschalten läuft' : 'Ausschalten läuft', 'fritzbox.second_factor_set', { enable: on, confirmed: true }); + if (!fbB) return; + const sf = await fbCmd('fritzbox.second_factor', {}); + if (fbB && sf.ok) fbB.sf = sf.result; + if (!r.ok && r.not_switchable) { _fbRender(''); uiAlert('Lässt sich nicht umschalten\n\n' + r.error); return; } + _fbShowResult(r, on ? 'Tastenbestätigung ist an' : 'Tastenbestätigung ist aus'); + } + async function fbBackupAll() { + if (!await uiConfirm('Alle FritzBoxen sichern?\n\nGilt für alle Boxen mit hinterlegtem Login an diesem Satelliten. Nicht erreichbare werden übersprungen. Verlangt eine Box eine Taste, wartet der Satellit bis 2 Minuten — sonst geht es mit der nächsten weiter.')) return; + const r = await _fbRun('Sichere alle FritzBoxen', 'fritzbox.backup_all', {}); + if (!fbB) return; + const l = await fbCmd('fritzbox.backups', {}); + if (fbB && l.ok) fbB.list = l.result.sicherungen || []; + _fbRender(''); + const el = document.getElementById('fb-run'); + el.style.display = ''; + if (!r.ok) { el.innerHTML = `⚠ Nicht geklappt
${escapeHtml(r.error || '')}
`; return; } + const res = r.result || {}; + el.innerHTML = `${res.gesichert} von ${res.von} gesichert` + (res.boxen || []).map(b => + `
${b.ok ? '✓' : '✗'} ${escapeHtml(b.box)}` + + (b.ok ? '' : ` — ${escapeHtml(b.grund || '')}`) + '
').join(''); + } 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', {}); diff --git a/satellite/satellite.py b/satellite/satellite.py index 354aa81..f4d5c80 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -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