feat: FritzBox-Sicherungen verwalten — Liste, Ansicht, Wiederherstellen, Loeschen

Satellit:
- sat_command laeuft als eigener Task (eine Aktion, die 2 min auf die
  Taste wartet, blockiert den Satelliten nicht mehr).
- Sicherung bekommt eine Begleitdatei (Box, Modell, Firmware, Seriennr.,
  Datum, QUELLE des Kennworts — nie der Wert).
- fritzbox.backups: Sicherungen je Box, neueste zuerst.
- fritzbox.backup_view: lesbar — Kopfdaten, wichtigste Einstellungen
  (Name, LAN/DHCP, WLAN, Kanal, DNS, SIP …), mit full alle Bereiche;
  verschluesselte Werte als «verschluesselt»; nur Dateien aus dem
  Sicherungsordner (kein Pfad von aussen).
- fritzbox.restore: X_AVM-DE_SetConfigFile — der Satellit stellt die Datei
  kurz per HTTP bereit (Zufallspfad, endet nach Abruf/10 min), die Box holt
  sie ab und startet neu. Nur mit confirmed, Taste per button_wait, mit dem
  Kennwort aus der Quelle der Sicherung; fremde Box nur mit other_box.
- fritzbox.backup_delete (mit confirmed). no_file fuer das Diagnostic.

Diagnostic: 💾 bei FritzBox-Geraeten → Sicherungen: Jetzt sichern,
ℹ (lesbar + Suche), Wiederherstellen, Loeschen; Sichern/Wiederherstellen
mit 2-Minuten-Countdown fuer die Taste, Ergebnis im Fenster (✓ / Taste
nicht gedrueckt / Grund), Erfolg blendet sich aus.

Brain: Beschreibung fuer Liste/Ansicht/Wiederherstellen/Loeschen.

Getestet: Satellit gegen nachgebaute Box (Sichern, Liste, Ansicht ohne
Geheimnisse, Pfadschutz, Wiederherstellen inkl. Abholen der Datei,
Loeschen), Diagnostic-Fenster in headless Chromium mit simuliertem
Satelliten. Gegen eine echte Box noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:52:04 +01:00
co-authored by Claude Opus 5.5
parent 5af03eedb9
commit 2bfa064ef6
4 changed files with 399 additions and 26 deletions
+10 -1
View File
@@ -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':"
"'<datei>'}) zeigt die wichtigsten Einstellungen daraus (Kennwoerter bleiben "
"verschluesselt). 'fritzbox.restore' (params={'ip':..,'file':'<datei>',"
"'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: <pfad>] 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) + "
+150 -1
View File
@@ -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 ? '🔑 ✓' : '🔑') + '</button>' : '';
// 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 ? '<button class="btn secondary" title="Sicherungen der FritzBox" ' +
'onclick="openFbBackups(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:6px;">💾</button>' : '';
const apkBtn = canAdb ? '<button class="btn secondary" title="ARIA-Agent (APK) auf dieses Gerät ausrollen" ' +
'onclick="openApkDeploy(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:6px;">📦</button>' : '';
@@ -6289,7 +6295,7 @@
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.hostname || d.id || '?') + '</span> ' +
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
'</div>' + nameBtn + keyBtn + apkBtn +
'</div>' + nameBtn + keyBtn + fbBtn + apkBtn +
'</div>';
}).join('') + '</div>';
}
@@ -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 = `<div style="background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:760px;width:100%;max-height:88vh;overflow-y:auto;color:#E0E0F0;font-size:13px;">${html}</div>`;
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) => `<tr style="border-bottom:1px solid #1E1E2E;">
<td style="padding:6px;">${escapeHtml(_fbFmtDate(b.erstellt) || b.datei)}</td>
<td style="padding:6px;color:#9a9ab5;">${escapeHtml(b.modell || '')}${b.firmware ? ' · FW ' + escapeHtml(b.firmware) : ''}</td>
<td style="padding:6px;color:#9a9ab5;">${_fmtBytes(b.bytes)}</td>
<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="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>
<button class="btn" onclick="fbCreateBackup()" ${fbB.busy ? 'disabled' : ''} style="margin-bottom:10px;">Jetzt sichern</button>
<div id="fb-run" style="${fbB.busy ? '' : 'display:none;'}margin-bottom:10px;padding:10px;border:1px solid #444466;border-radius:6px;"></div>
${fbB.list.length ? `<table style="width:100%;border-collapse:collapse;font-size:12px;"><tbody>${rows}</tbody></table>`
: '<div style="color:#6a6a88;">Noch keine Sicherung.</div>'}
<div style="color:#8888AA;font-size:11px;margin-top:8px;min-height:14px;">${escapeHtml(status || '')}</div>`);
}
// 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 = `<b>${escapeHtml(label)} …</b><div style="color:#FFD60A;margin-top:4px;">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')}</div>`;
};
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 = `<b style="color:#34C759;">✓ ${escapeHtml(okText)}</b>` + (r.result && r.result.hinweis ? `<div style="color:#9a9ab5;margin-top:4px;">${escapeHtml(r.result.hinweis)}</div>` : '');
setTimeout(() => { const e = document.getElementById('fb-run'); if (e && fbB && !fbB.busy) e.style.display = 'none'; }, 6000);
} else {
el.innerHTML = `<b style="color:#FF9F0A;">⚠ ${r.timeout ? 'Taste nicht gedrückt' : 'Nicht geklappt'}</b><div style="margin-top:4px;">${escapeHtml(r.error || 'unbekannter Fehler')}</div>`;
}
}
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]) => `<tr><td style="padding:2px 8px 2px 0;color:#8888AA;">${escapeHtml(k)}</td><td>${escapeHtml(x)}</td></tr>`).join('');
const wichtig = (v.wichtig || []).map(w => `<tr><td style="padding:2px 8px 2px 0;color:#8888AA;">${escapeHtml(w.was)}</td><td>${escapeHtml(w.wert)}</td><td style="color:#555570;padding-left:8px;">${escapeHtml(w.datei)}</td></tr>`).join('');
const secs = Object.entries(v.inhalt || {}).map(([n, t]) => `<details data-text="${escapeHtml(t.toLowerCase())}" style="margin-top:4px;"><summary style="cursor:pointer;color:#4DB8FF;">${escapeHtml(n)} <span style="color:#555570;">(${_fmtBytes(t.length)})</span></summary>
<pre style="white-space:pre-wrap;word-break:break-all;background:#000;border-radius:6px;padding:6px 8px;font-size:11px;max-height:320px;overflow:auto;">${escapeHtml(t)}</pre></details>`).join('');
_fbModal(`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h3 style="margin:0;">ℹ Sicherung vom ${escapeHtml(_fbFmtDate(b.erstellt))}</h3>
<button onclick="_fbRender('')" style="background:none;border:none;color:#8888AA;font-size:13px;cursor:pointer;">← zurück</button></div>
<p style="color:#8888AA;font-size:11px;margin:0 0 8px;">Verschlüsselte Werte (Kennwörter, Schlüssel) stehen als «verschluesselt» da.</p>
${kopf ? `<table style="font-size:12px;margin-bottom:10px;">${kopf}</table>` : ''}
${wichtig ? `<h4 style="margin:8px 0 4px;">Wichtigste Einstellungen</h4><table style="font-size:12px;">${wichtig}</table>` : ''}
<h4 style="margin:12px 0 4px;">Alle Bereiche</h4>
<input type="search" placeholder="🔍 in der Sicherung suchen …" oninput="fbFilterView(this.value)" style="width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 8px;color:#E0E0F0;font-size:12px;margin-bottom:4px;">
<div id="fb-view-secs">${secs || '<span style="color:#6a6a88;">Keine lesbaren Bereiche.</span>'}</div>`);
}
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;
+4
View File
@@ -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)
+235 -24
View File
@@ -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):