From c77526e92a95680d90b9fc8a48d2142f583f12f1 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 31 Oct 2026 18:17:12 +0100 Subject: [PATCH] feat: FritzBox-Sicherung herunterladen + maximale Anzahl je Box einstellbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Satellit: fritzbox.backup_get liefert eine vorhandene Sicherung (nur aus dem Sicherungsordner, bis 20 MB). Bei ARIA landet sie unter /shared/uploads/ und kann in den Chat. - Zugangsdaten FritzBox: "Maximale Sicherungen" (1–100, leer = 10). Bei jeder neuen Sicherung fliegt die aelteste dieser Box raus; die Liste meldet die Zahl mit (Kopfzeile im 💾-Fenster). - Diagnostic: ⬇ je Sicherung laedt die .export-Datei herunter. Getestet: Satellit (Limit 2 → die zwei neuesten bleiben, Download, Pfad- schutz), Diagnostic in headless Chromium (Download-Inhalt, Feld laden, 0 abgelehnt, 20 gespeichert). Co-Authored-By: Claude Opus 5.5 --- aria-brain/agent.py | 4 +++- diagnostic/index.html | 35 +++++++++++++++++++++++++++++++---- satellite/satellite.py | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 9b15c6c..3147fb1 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1325,7 +1325,9 @@ 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). ALLE BOXEN SICHERN: 'fritzbox.backup_all' " + "loescht eine Sicherung (nach Ja). 'fritzbox.backup_get' (params={'ip':..," + "'file':..}) holt eine VORHANDENE Sicherung — sie liegt danach unter " + "/shared/uploads/ und kommt mit [FILE: ] in den Chat. 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 " diff --git a/diagnostic/index.html b/diagnostic/index.html index ecef0f5..44ec0b4 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -330,7 +330,9 @@
-
+
+
+
@@ -6351,11 +6353,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: '', sf: null }; + fbB = { sat, ip, name, list: [], busy: '', sf: null, keep: 0 }; _fbRender('Lade Sicherungen …'); 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.keep = (r.ok && r.result && r.result.behalten) || 0; fbB.sf = sf.ok ? sf.result : { fehler: sf.error }; _fbRender(r.ok ? '' : '⚠ ' + (r.error || 'Fehler')); } @@ -6368,12 +6371,13 @@ ${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 (🔑).

+

Liegen auf dem Satelliten, die letzten ${fbB.keep || 10} je Box (einstellbar unter 🔑). Verschlüsselt mit dem hinterlegten Sicherungs-Kennwort (🔑).

${_fbSfLine()}
@@ -6468,6 +6472,23 @@ const r = await _fbRun('Wiederherstellen läuft', 'fritzbox.restore', { file: b.datei, confirmed: true }); if (fbB) _fbShowResult(r, 'Sicherung eingespielt'); } + async function fbDownloadBackup(i) { + const b = fbB.list[i]; + if (!b) return; + _fbRender('Lade ' + b.datei + ' …'); + const r = await fbCmd('fritzbox.backup_get', { file: b.datei }, 120000); + if (!fbB) return; + if (!r.ok || !r.result || !r.result.file_b64) { _fbRender('⚠ ' + (r.error || 'Download fehlgeschlagen')); return; } + const bin = atob(r.result.file_b64); + const bytes = new Uint8Array(bin.length); + for (let k = 0; k < bin.length; k++) bytes[k] = bin.charCodeAt(k); + const url = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' })); + const a = document.createElement('a'); + a.href = url; a.download = r.result.filename || b.datei; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + _fbRender('⬇ ' + (r.result.filename || b.datei) + ' heruntergeladen.'); + } 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; @@ -6528,7 +6549,7 @@ credsModalSat = satId; credsModalIp = ip; dcStored = {}; dcRevealed = false; dcFilled = false; dcFbReq = ''; _dcEl('dev-creds-title').textContent = (name || ip) + ' (' + ip + ')'; - ['dc-snmp-community','dc-v3-user','dc-v3-authkey','dc-v3-privkey','dc-http-user','dc-http-pass','dc-fb-user','dc-fb-pass','dc-fb-backuppass','dc-ssh-user','dc-ssh-port','dc-ssh-pass','dc-ssh-key'].forEach(id => { + ['dc-snmp-community','dc-v3-user','dc-v3-authkey','dc-v3-privkey','dc-http-user','dc-http-pass','dc-fb-user','dc-fb-pass','dc-fb-backuppass','dc-fb-keep','dc-ssh-user','dc-ssh-port','dc-ssh-pass','dc-ssh-key'].forEach(id => { const el = _dcEl(id); el.value = ''; if (el.dataset.ph == null) el.dataset.ph = el.placeholder; @@ -6555,6 +6576,7 @@ if (v3.privProto) _dcEl('dc-v3-privproto').value = v3.privProto; _dcEl('dc-http-user').value = (detail.http || {}).user || ''; _dcEl('dc-fb-user').value = (detail.fritzbox || {}).user || ''; + _dcEl('dc-fb-keep').value = (detail.fritzbox || {}).backupKeep || ''; _dcEl('dc-ssh-user').value = (detail.ssh || {}).user || ''; _dcEl('dc-ssh-port').value = (detail.ssh || {}).port || ''; } @@ -6638,8 +6660,13 @@ if (!/[^A-Za-z0-9]/.test(fbp)) miss.push('ein Sonderzeichen'); if (miss.length) { document.getElementById('dc-status').textContent = '⚠ Sicherungs-Kennwort zu schwach — es fehlt: ' + miss.join(', ') + '.'; return; } } + const fkeep = parseInt(document.getElementById('dc-fb-keep').value, 10); + if (document.getElementById('dc-fb-keep').value && !(fkeep >= 1 && fkeep <= 100)) { + document.getElementById('dc-status').textContent = '⚠ Maximale Sicherungen: eine Zahl von 1 bis 100.'; return; + } if (fu || fp || fbp || dcStored['dc-fb-pass'] || dcStored['dc-fb-backuppass']) { creds.fritzbox = { user: fu }; + if (fkeep) creds.fritzbox.backupKeep = fkeep; if (fp) creds.fritzbox.pass = fp; if (fbp) creds.fritzbox.backupPass = fbp; } diff --git a/satellite/satellite.py b/satellite/satellite.py index 69e4ec5..a155343 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -1465,6 +1465,16 @@ 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_keep(ip: str) -> int: + """Wie viele Sicherungen je Box bleiben: Zugangsdaten (Feld "Maximale + Sicherungen") > FB_BACKUP_KEEP (Standard 10). Aelteste fliegen zuerst.""" + try: + n = int(_creds_for(ip).get("fritzbox", {}).get("backupKeep") or 0) + except (TypeError, ValueError): + n = 0 + return max(1, min(100, n)) if n else FB_BACKUP_KEEP + + 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 @@ -1517,8 +1527,9 @@ def _fb_backup(ip: str, user: str, pw: str, params: dict) -> dict: "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) + keep = _fb_backup_keep(ip) 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]: + for f in old[:-keep]: for ext in (".export", ".json"): try: os.remove(os.path.join(FB_BACKUP_DIR, f[:-len(".export")] + ext)) @@ -1537,7 +1548,7 @@ def _fb_backup(ip: str, user: str, pw: str, params: dict) -> dict: "kennwort": ("das angegebene Kennwort" if params.get("password") else "das im Satelliten hinterlegte Sicherungs-Kennwort" if stored else "das FritzBox-Login-Kennwort"), - "hinweis": f"Liegt auf dem Satelliten, die letzten {FB_BACKUP_KEEP} je Box bleiben. " + "hinweis": f"Liegt auf dem Satelliten, die letzten {keep} je Box bleiben. " "Zum Wiederherstellen in der FritzBox: System → Sicherung → Wiederherstellen, mit dem Kennwort."}} @@ -1600,7 +1611,8 @@ def _fb_backups(ip: str, params: dict) -> dict: 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}} + return {"ok": True, "result": {"ip": ip, "count": len(out), "sicherungen": out, + "behalten": _fb_backup_keep(ip)}} # Lesbare Hinweise aus der Sicherung: AVM-Konfig "schluessel = wert;" @@ -1819,6 +1831,20 @@ def _fb_second_factor_set(ip: str, user: str, pw: str, params: dict) -> dict: "button_wait": params.get("button_wait")}) +def _fb_backup_get(params: dict) -> dict: + """Eine vorhandene Sicherung als Datei zurueckgeben (Download im Diagnostic, + bei ARIA landet sie unter /shared/uploads/ fuer den Chat).""" + 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)."} + data = open(path, "rb").read() + if len(data) > 20 * 1024 * 1024: + return {"ok": False, "error": "Sicherung zu gross fuer die Uebertragung (> 20 MB)."} + return {"ok": True, "result": {"datei": os.path.basename(path), "bytes": len(data), + "filename": os.path.basename(path), + "file_b64": base64.b64encode(data).decode()}} + + def _fb_backup_delete(params: dict) -> dict: path = _fb_backup_path(params.get("file") or params.get("datei") or "") if not path: @@ -1850,6 +1876,8 @@ def _do_fritzbox(action: str, params: dict) -> dict: return _fb_backup_view(params) if action == "fritzbox.backup_delete": return _fb_backup_delete(params) + if action == "fritzbox.backup_get": + return _fb_backup_get(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 "")