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 " "(params={'ip':..,'button_wait':true}) sichert die Einstellungen — OHNE "
"'password': der Satellit nimmt das hinterlegte Sicherungs-Kennwort (das " "'password': der Satellit nimmt das hinterlegte Sicherungs-Kennwort (das "
"Du nicht kennst und nicht kennen sollst); 'password' nur, wenn Stefan " "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':'...'}) " "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) " "— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + " "und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
+150 -1
View File
@@ -6073,6 +6073,7 @@
requestId: apkRequestId, params: { ip: apkTarget.ip } }); requestId: apkRequestId, params: { ip: apkTarget.ip } });
} }
function onSatResult(p) { 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 (p && dcFbReq && p.requestId === dcFbReq) { dcOnFritzUsers(p); return; }
if (!apkRequestId || !p || p.requestId !== apkRequestId) return; if (!apkRequestId || !p || p.requestId !== apkRequestId) return;
const status = document.getElementById('apk-status'); const status = document.getElementById('apk-status');
@@ -6271,6 +6272,11 @@
'style="padding:1px 6px;font-size:11px;margin-left:8px;">' + (hasCreds ? '🔑 ✓' : '🔑') + '</button>' : ''; 'style="padding:1px 6px;font-size:11px;margin-left:8px;">' + (hasCreds ? '🔑 ✓' : '🔑') + '</button>' : '';
// APK ausrollen: bei ADB-faehigen Geraeten (Fire TV & Co.). // APK ausrollen: bei ADB-faehigen Geraeten (Fire TV & Co.).
const canAdb = d.ip && (d.adbCapable || ['firetv', 'android', 'android-adb', 'androidtv', 'dial'].includes(d.type)); 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" ' + 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) + '\')" ' + 'onclick="openApkDeploy(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:6px;">📦</button>' : ''; '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:#E0E0F0;">' + escapeHtml(d.name || d.hostname || d.id || '?') + '</span> ' +
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' + '<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr + '<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
'</div>' + nameBtn + keyBtn + apkBtn + '</div>' + nameBtn + keyBtn + fbBtn + apkBtn +
'</div>'; '</div>';
}).join('') + '</div>'; }).join('') + '</div>';
} }
@@ -6315,6 +6321,149 @@
}).join(''); }).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 ──────────────────────────────── // ── Geraete-Credentials ────────────────────────────────
function dcToggleV3() { function dcToggleV3() {
const v = document.getElementById('dc-snmp-version').value; 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.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.call FritzBox: beliebige TR-064-Aktion; aendernde nur mit confirmed=true
# fritzbox.wlan FritzBox: alle WLANs (inkl. Gast) anzeigen / schalten # 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) # (fritzbox.* in der Liste erlaubt alle fritzbox-Aktionen)
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh') # 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) # 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 re
import socket import socket
import struct import struct
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -1484,17 +1485,29 @@ def _fb_backup(ip: str, user: str, pw: str, params: dict) -> dict:
os.chmod(path, 0o600) os.chmod(path, 0o600)
except OSError: except OSError:
pass 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")) 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[:-FB_BACKUP_KEEP]:
try: for ext in (".export", ".json"):
os.remove(os.path.join(FB_BACKUP_DIR, f)) try:
except OSError: os.remove(os.path.join(FB_BACKUP_DIR, f[:-len(".export")] + ext))
pass except OSError:
pass
logger.info("[fritzbox] %s: Sicherung %s (%d Bytes)", ip, fname, len(d.content)) logger.info("[fritzbox] %s: Sicherung %s (%d Bytes)", ip, fname, len(d.content))
import base64 import base64
# Die Datei geht mit zurueck — ARIAs Brain legt sie unter /shared/uploads/ # 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. # 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": { return {"ok": True, "result": {
"ip": ip, "datei": path, "bytes": len(d.content), "auf": "Satellit", "ip": ip, "datei": path, "bytes": len(d.content), "auf": "Satellit",
**({"file_b64": file_b64, "filename": fname} if file_b64 else {}), **({"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)."}} "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: def _do_fritzbox(action: str, params: dict) -> dict:
"""fritzbox.info -> Modell/Firmware/Verbindung/externe IP/Datenrate. """fritzbox.info -> Modell/Firmware/Verbindung/externe IP/Datenrate.
fritzbox.hosts -> Liste der bekannten Geraete (Name/IP/MAC/aktiv) plus 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."} return {"ok": False, "error": "ip/host erforderlich."}
if action == "fritzbox.describe": # braucht keine Anmeldung if action == "fritzbox.describe": # braucht keine Anmeldung
return _fb_describe(ip, params) 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", {}) fb = _creds_for(ip).get("fritzbox", {})
user = str(params.get("user") or fb.get("user") or "") user = str(params.get("user") or fb.get("user") or "")
pw = str(params.get("pass") or params.get("password") or fb.get("pass") 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) return _fb_wlan(ip, user, pw, params)
if action == "fritzbox.backup": if action == "fritzbox.backup":
return _fb_backup(ip, user, pw, params) return _fb_backup(ip, user, pw, params)
if action == "fritzbox.restore":
return _fb_restore(ip, user, pw, params)
if action == "fritzbox.hosts": if action == "fritzbox.hosts":
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1", p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
@@ -2463,6 +2669,27 @@ class Satellite:
"timestamp": int(time.time() * 1000), "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: async def _handle(self, raw: str) -> None:
try: try:
msg = json.loads(raw) msg = json.loads(raw)
@@ -2525,25 +2752,9 @@ class Satellite:
elif mtype == "sat_command": elif mtype == "sat_command":
if not self._for_me(payload): if not self._for_me(payload):
return return
req_id = payload.get("requestId", "") # Eigener Task: eine Aktion kann lange dauern (FritzBox wartet bis
action = (payload.get("action") or "").strip() # 2 min auf die Taste, adb.install Minuten) — der Loop bleibt frei.
params = payload.get("params") or {} asyncio.create_task(self._command_reply(payload))
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),
})
elif mtype == "sat_creds_set": elif mtype == "sat_creds_set":
if not self._for_me(payload): if not self._for_me(payload):