feat: FritzBox-Sicherung herunterladen + maximale Anzahl je Box einstellbar

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:17:12 +01:00
co-authored by Claude Opus 5.5
parent 0a1e334169
commit c77526e92a
3 changed files with 65 additions and 8 deletions
+3 -1
View File
@@ -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: <pfad>] 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 "
+31 -4
View File
@@ -330,7 +330,9 @@
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;"><input id="dc-fb-user" placeholder="FritzBox-Benutzer (z.B. fritz1234)" style="flex:1;">
<button type="button" id="dc-fb-detect" class="btn secondary" onclick="dcDetectFritzUser()" title="Fragt die FritzBox, welche Benutzer es gibt (geht ohne Anmeldung)" style="padding:2px 8px;font-size:11px;white-space:nowrap;">Benutzer ermitteln</button></div>
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;"><input id="dc-fb-pass" type="password" placeholder="FritzBox-Passwort" style="flex:1;"><button type="button" class="btn secondary" onclick="dcEye('dc-fb-pass')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
<div style="display:flex;gap:4px;align-items:center;"><input id="dc-fb-backuppass" type="password" placeholder="Kennwort für Sicherungen (mind. 12 Zeichen, Aa, 1, !)" title="Damit verschlüsselt ARIA Sicherungen der Box. ARIA nutzt es, kennt es aber nicht — zum Wiederherstellen brauchst du es." style="flex:1;"><button type="button" class="btn secondary" onclick="dcEye('dc-fb-backuppass')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;"><input id="dc-fb-backuppass" type="password" placeholder="Kennwort für Sicherungen (mind. 12 Zeichen, Aa, 1, !)" title="Damit verschlüsselt ARIA Sicherungen der Box. ARIA nutzt es, kennt es aber nicht — zum Wiederherstellen brauchst du es." style="flex:1;"><button type="button" class="btn secondary" onclick="dcEye('dc-fb-backuppass')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
<div style="display:flex;gap:6px;align-items:center;"><label for="dc-fb-keep" style="font-size:11px;color:#8888AA;">Maximale Sicherungen</label>
<input id="dc-fb-keep" type="number" min="1" max="100" placeholder="10" title="So viele Sicherungen dieser Box bleiben auf dem Satelliten — bei jeder neuen fliegt die älteste raus" style="width:70px;"></div>
</div>
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
@@ -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 @@
<td style="padding:6px;color:#9a9ab5;">${escapeHtml(_fbPwSrc[b.kennwort] || b.kennwort || '')}</td>
<td style="padding:6px;white-space:nowrap;">
<button class="btn secondary" onclick="fbViewBackup(${i})" title="Inhalt ansehen" style="padding:2px 8px;font-size:11px;">ℹ</button>
<button class="btn secondary" onclick="fbDownloadBackup(${i})" title="Herunterladen (.export, verschlüsselt mit dem Sicherungs-Kennwort)" style="padding:2px 8px;font-size:11px;">⬇</button>
<button class="btn secondary" onclick="fbRestoreBackup(${i})" title="Diese Sicherung einspielen" style="padding:2px 8px;font-size:11px;">Wiederherstellen</button>
<button class="btn secondary" onclick="fbDeleteBackup(${i})" title="Löschen" style="padding:2px 8px;font-size:11px;color:#FF6E6E;">🗑</button></td></tr>`).join('');
_fbModal(`<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:8px;">
<h3 style="margin:0;">💾 Sicherungen: ${escapeHtml(fbB.name)} <span style="color:#8888AA;font-size:12px;">(${escapeHtml(fbB.ip)})</span></h3>
<button onclick="closeFbBackups()" style="background:none;border:none;color:#8888AA;font-size:22px;cursor:pointer;">&times;</button></div>
<p style="color:#8888AA;font-size:11px;margin:0 0 10px;">Liegen auf dem Satelliten, die letzten 10 je Box. Verschlüsselt mit dem hinterlegten Sicherungs-Kennwort (🔑).</p>
<p style="color:#8888AA;font-size:11px;margin:0 0 10px;">Liegen auf dem Satelliten, die letzten ${fbB.keep || 10} je Box (einstellbar unter 🔑). Verschlüsselt mit dem hinterlegten Sicherungs-Kennwort (🔑).</p>
${_fbSfLine()}
<div style="display:flex;gap:8px;margin-bottom:10px;">
<button class="btn" onclick="fbCreateBackup()" ${fbB.busy ? 'disabled' : ''}>Jetzt sichern</button>
@@ -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;
}
+31 -3
View File
@@ -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 "")