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
+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;