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:
+10
-1
@@ -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
@@ -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;">×</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;
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user