feat: alle FritzBoxen sichern (ueberspringt statt abzubrechen) + Tastenbestaetigung anzeigen/schalten

Satellit:
- fritzbox.backup_all: alle Boxen mit hinterlegtem Login nacheinander;
  nicht erreichbar (Port 49000), Fehler oder Taste noetig → notieren und
  weiter. Ergebnis: x von y gesichert + Grund je Box.
- fritzbox.second_factor: ist die Zusatzbestaetigung an, gibt es sie, und
  laesst sie sich per TR-064 schalten (nur wenn die Firmware eine Set-
  Aktion mit Enable-Argument im Auth-Dienst anbietet).
- fritzbox.second_factor_set: schaltet sie (confirmed, Taste per
  button_wait); sonst not_switchable + Weg ueber die Oberflaeche.

Diagnostic (💾-Fenster): Zeile "Tastenbestaetigung: an/aus" mit Aus-/
Einschalten (Rueckfrage, Countdown; nicht schaltbar → Hinweisfenster),
Knopf "Alle FritzBoxen sichern" mit Ergebnis je Box. Ausschalten/
Deaktivieren gilt jetzt als gefaehrliche Rueckfrage (roter Knopf).

Brain: Beschreibung fuer backup_all (je Satellit, weiter bei Fehlern) und
second_factor; Timeout 15 min fuer backup_all.

Getestet gegen nachgebaute Box (eine Box unerreichbar → uebersprungen,
Taste noetig ohne/mit Warten, Status, Schalten mit und ohne Setter) und das
Fenster in headless Chromium. Ob echte FRITZ!OS-Versionen das Schalten
per TR-064 anbieten, ist ungeprueft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:55:06 +01:00
co-authored by Claude Opus 5.5
parent 2bfa064ef6
commit 236e94da4a
3 changed files with 145 additions and 7 deletions
+10 -1
View File
@@ -1325,7 +1325,15 @@ META_TOOLS = [
"Box uebernimmt alles und startet neu (1–3 min kein Netz); vorher Datum der "
"Sicherung nennen und Stefans ausdrueckliches Ja holen, ebenso ob er an der "
"Box steht. 'fritzbox.backup_delete' (params={'ip':..,'file':..,'confirmed':true}) "
"loescht eine Sicherung (nach Ja). die Datei liegt danach unter /shared/uploads/ "
"loescht eine Sicherung (nach Ja). ALLE BOXEN SICHERN: 'fritzbox.backup_all' "
"(params={} oder {'button_wait':true}) — pro Satellit einmal aufrufen (jeder "
"Satellit sichert seine Boxen mit hinterlegtem Login); nicht erreichbare oder "
"fehlerhafte werden uebersprungen, es geht mit der naechsten weiter — am Ende "
"die Liste je Box (✓/✗ + Grund) an Stefan. TASTENBESTAETIGUNG: "
"'fritzbox.second_factor' zeigt, ob sie an ist und ob sie sich per Schnittstelle "
"schalten laesst; 'fritzbox.second_factor_set' (params={'ip':..,'enable':false,"
"'confirmed':true,'button_wait':true}) schaltet sie — nur nach Stefans Ja; "
"geht es nicht, den Hinweis (Weg ueber die Oberflaeche) weitergeben. 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) + "
@@ -3266,6 +3274,7 @@ class Agent:
sat_timeout = (320.0 if action.startswith("adb.")
else 200.0 if action == "ssh.exec"
# button_wait: bis 2 min auf die Taste an der FritzBox
else 900.0 if action == "fritzbox.backup_all"
else 200.0 if action.startswith("fritzbox.") and params.get("button_wait")
else 60.0 if action.startswith("fritzbox.") else 20.0)
result = _post("/internal/satellite", {
+48 -5
View File
@@ -4487,7 +4487,7 @@
// uiConfirm → true/false, uiPrompt → Text oder null, uiAlert → undefined.
// Erster Absatz der Meldung = Ueberschrift, der Rest = Erklaerung.
// Esc / Klick daneben = Abbrechen, Enter = OK.
const _UI_DANGER = /l[öo]e?schen|entfernen|zur[üu]cksetzen|NOT-AUS|AGGRESSIV|WIRKLICH|endg[üu]ltig|archivieren|beenden|verloren|[üu]berschrieben|leeren/i;
const _UI_DANGER = /l[öo]e?schen|entfernen|zur[üu]cksetzen|NOT-AUS|AGGRESSIV|WIRKLICH|endg[üu]ltig|archivieren|beenden|verloren|[üu]berschrieben|leeren|ausschalten|deaktivier|zur[üu]cksetzen/i;
function _uiDialog({ message, input, defaultValue, buttons }) {
return new Promise(resolve => {
const text = String(message ?? '');
@@ -6351,11 +6351,12 @@
const _fbFmtDate = iso => { try { return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); } catch (_) { return iso; } };
const _fbPwSrc = { backupPass: 'Sicherungs-Kennwort', login: 'FritzBox-Kennwort', angegeben: 'eigenes Kennwort' };
async function openFbBackups(sat, ip, name) {
fbB = { sat, ip, name, list: [], busy: '' };
fbB = { sat, ip, name, list: [], busy: '', sf: null };
_fbRender('Lade Sicherungen …');
const r = await fbCmd('fritzbox.backups', {});
const [r, sf] = await Promise.all([fbCmd('fritzbox.backups', {}), fbCmd('fritzbox.second_factor', {})]);
if (!fbB) return;
fbB.list = (r.ok && r.result && r.result.sicherungen) || [];
fbB.sf = sf.ok ? sf.result : { fehler: sf.error };
_fbRender(r.ok ? '' : '⚠ ' + (r.error || 'Fehler'));
}
function _fbRender(status) {
@@ -6373,7 +6374,10 @@
<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>
${_fbSfLine()}
<div style="display:flex;gap:8px;margin-bottom:10px;">
<button class="btn" onclick="fbCreateBackup()" ${fbB.busy ? 'disabled' : ''}>Jetzt sichern</button>
<button class="btn secondary" onclick="fbBackupAll()" ${fbB.busy ? 'disabled' : ''} title="Alle FritzBoxen mit hinterlegtem Login an diesem Satelliten — nicht erreichbare werden übersprungen">Alle FritzBoxen sichern</button></div>
<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>'}
@@ -6393,7 +6397,8 @@
};
tick();
const iv = setInterval(tick, 1000);
const r = await fbCmd(cmd, { ...params, button_wait: true, no_file: true }, 170000);
// "alle sichern": pro Box bis 2 min Taste — insgesamt grosszuegig warten
const r = await fbCmd(cmd, { ...params, button_wait: true, no_file: true }, cmd === 'fritzbox.backup_all' ? 15 * 60000 : 170000);
clearInterval(iv);
if (!fbB) return r;
fbB.busy = '';
@@ -6410,6 +6415,44 @@
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>`;
}
}
// Tastenbestaetigung der Box: Status + Umschalten (wenn die Firmware es kann)
function _fbSfLine() {
const sf = fbB.sf;
if (!sf) return '<div style="color:#6a6a88;font-size:12px;margin-bottom:8px;">Tastenbestätigung: wird geprüft …</div>';
if (sf.fehler) return `<div style="color:#6a6a88;font-size:12px;margin-bottom:8px;">Tastenbestätigung: unbekannt (${escapeHtml(sf.fehler)})</div>`;
if (!sf.vorhanden) return '<div style="color:#6a6a88;font-size:12px;margin-bottom:8px;">Tastenbestätigung: gibt es bei dieser Box nicht — alles läuft ohne Taste.</div>';
const st = sf.an === true ? '<b style="color:#FFD60A;">an</b>' : sf.an === false ? '<b style="color:#34C759;">aus</b>' : '<b>unbekannt</b>';
const btn = sf.an === null ? '' : `<button class="btn secondary" onclick="fbToggleSf(${sf.an ? 'false' : 'true'})" ${fbB.busy ? 'disabled' : ''} style="padding:2px 8px;font-size:11px;margin-left:8px;">${sf.an ? 'Ausschalten' : 'Einschalten'}</button>`;
return `<div style="font-size:12px;margin-bottom:8px;">Tastenbestätigung: ${st}${btn}</div>`;
}
async function fbToggleSf(on) {
if (fbB.sf && !fbB.sf.schaltbar) { uiAlert('Lässt sich nicht umschalten\n\n' + (fbB.sf.hinweis || '')); return; }
if (!await uiConfirm(`Tastenbestätigung an ${fbB.name} ${on ? 'einschalten' : 'ausschalten'}?\n\n`
+ (on ? 'Danach verlangt die Box für Sicherungen und heikle Einstellungen wieder einen Tastendruck.'
: 'Danach laufen Sicherungen ohne Tastendruck — auch „alle FritzBoxen sichern“. Wer das Login kennt, kann dann aber auch heikle Einstellungen ohne Bestätigung am Gerät ändern.')
+ '\n\nDas Umschalten selbst muss die Box meist per Taste bestätigen (2 Minuten).')) return;
const r = await _fbRun(on ? 'Einschalten läuft' : 'Ausschalten läuft', 'fritzbox.second_factor_set', { enable: on, confirmed: true });
if (!fbB) return;
const sf = await fbCmd('fritzbox.second_factor', {});
if (fbB && sf.ok) fbB.sf = sf.result;
if (!r.ok && r.not_switchable) { _fbRender(''); uiAlert('Lässt sich nicht umschalten\n\n' + r.error); return; }
_fbShowResult(r, on ? 'Tastenbestätigung ist an' : 'Tastenbestätigung ist aus');
}
async function fbBackupAll() {
if (!await uiConfirm('Alle FritzBoxen sichern?\n\nGilt für alle Boxen mit hinterlegtem Login an diesem Satelliten. Nicht erreichbare werden übersprungen. Verlangt eine Box eine Taste, wartet der Satellit bis 2 Minuten — sonst geht es mit der nächsten weiter.')) return;
const r = await _fbRun('Sichere alle FritzBoxen', 'fritzbox.backup_all', {});
if (!fbB) return;
const l = await fbCmd('fritzbox.backups', {});
if (fbB && l.ok) fbB.list = l.result.sicherungen || [];
_fbRender('');
const el = document.getElementById('fb-run');
el.style.display = '';
if (!r.ok) { el.innerHTML = `<b style="color:#FF9F0A;">⚠ Nicht geklappt</b><div>${escapeHtml(r.error || '')}</div>`; return; }
const res = r.result || {};
el.innerHTML = `<b>${res.gesichert} von ${res.von} gesichert</b>` + (res.boxen || []).map(b =>
`<div style="margin-top:3px;">${b.ok ? '<span style="color:#34C759;">✓</span>' : '<span style="color:#FF9F0A;">✗</span>'} ${escapeHtml(b.box)}`
+ (b.ok ? '' : ` — <span style="color:#9a9ab5;">${escapeHtml(b.grund || '')}</span>`) + '</div>').join('');
}
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', {});
+87 -1
View File
@@ -875,6 +875,8 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
return await loop.run_in_executor(None, _do_snmp_ports, params)
if action == "snmp.info":
return await loop.run_in_executor(None, _do_snmp_info, params)
if action == "fritzbox.backup_all":
return await loop.run_in_executor(None, _fb_backup_all, params, devices)
if action.startswith("fritzbox."):
# Geraetename ("Fritzbox Keller") → IP, wie bei den anderen Aktionen
t = _snmp_target(params)
@@ -1716,6 +1718,86 @@ def _fb_restore(ip: str, user: str, pw: str, params: dict) -> dict:
"den Satelliten? In ein paar Minuten pruefen, ob die Einstellungen uebernommen sind.")}}
def _fb_reachable(ip: str, timeout: float = 3.0) -> bool:
try:
with socket.create_connection((ip, 49000), timeout=timeout):
return True
except OSError:
return False
def _fb_backup_all(params: dict, devices: list) -> dict:
"""Sichert ALLE FritzBoxen mit hinterlegtem Login, eine nach der anderen.
Nicht erreichbar / Fehler / Taste noetig → notieren und WEITER, nie abbrechen."""
names = {d.get("ip"): d.get("name") for d in devices if d.get("ip")}
boxes = [ip for ip, e in sorted(_CREDS.items()) if (e.get("fritzbox") or {}).get("pass")]
if not boxes:
return {"ok": False, "error": "An diesem Satelliten ist fuer keine FritzBox ein Login hinterlegt."}
report = []
for ip in boxes:
name = names.get(ip) or ip
if not _fb_reachable(ip):
report.append({"box": name, "ip": ip, "ok": False, "grund": "nicht erreichbar (aus/weg?) — uebersprungen"})
continue
fb = _creds_for(ip).get("fritzbox", {})
try:
r = _fb_backup(ip, str(fb.get("user") or ""), str(fb.get("pass") or ""),
{**params, "name": name, "no_file": True})
except Exception as exc:
r = {"ok": False, "error": f"Fehler: {exc}"}
if r.get("ok"):
report.append({"box": name, "ip": ip, "ok": True, "datei": os.path.basename(r["result"]["datei"])})
else:
grund = ("braucht eine Taste an der Box — uebersprungen (mit button_wait einzeln nachholen)"
if r.get("needs_button") else r.get("error") or "Fehler")
report.append({"box": name, "ip": ip, "ok": False, "grund": grund})
ok = sum(1 for x in report if x["ok"])
return {"ok": True, "result": {"gesichert": ok, "von": len(report), "boxen": report}}
# ─── Zusatzbestaetigung (Taste) der Box: Status + Umschalten ───
def _fb_second_factor_info(ip: str, user: str, pw: str) -> dict:
"""Ist die Tastenbestaetigung an? Und laesst sie sich per TR-064 schalten?
Umschalten geht nur, wenn die Firmware dafuer eine Aktion anbietet (eine
Set-Aktion des Auth-Dienstes mit einem Enable-Argument) — sonst nur in der
FritzBox-Oberflaeche."""
auth = _fb_find_service(ip, "X_AVM-DE_Auth")
if not auth:
return {"ok": True, "result": {"vorhanden": False, "an": False, "schaltbar": False,
"hinweis": "Die Box kennt keine Zusatzbestaetigung per Schnittstelle — Aktionen laufen ohne Taste."}}
acts = _fb_actions(ip, auth)
setter = next((a for a in acts if not _fb_is_read(a["name"]) and a["name"] != "SetConfig"
and any("enable" in x.lower() for x in a["in"])), None)
info = _tr064(ip, user, pw, auth["type"], auth["control"], "GetInfo") \
if any(a["name"] == "GetInfo" for a in acts) else {"ok": False}
f = info.get("fields", {}) if info.get("ok") else {}
en = f.get("NewEnabled", f.get("NewEnable"))
return {"ok": True, "result": {
"vorhanden": True, "an": None if en is None else en in ("1", "true"),
"schaltbar": bool(setter), "aktion": setter["name"] if setter else "",
"hinweis": ("" if setter else
"Laesst sich ueber die Schnittstelle nicht umschalten (Firmware bietet es nicht an). "
"Nur in der FritzBox-Oberflaeche: System → FRITZ!Box-Benutzer → Anmeldung im Heimnetz → "
"\"Ausfuehrung bestimmter Einstellungen und Funktionen zusaetzlich bestaetigen\". "
"Manche Firmware erlaubt das Abschalten gar nicht mehr.")}}
def _fb_second_factor_set(ip: str, user: str, pw: str, params: dict) -> dict:
inf = _fb_second_factor_info(ip, user, pw)["result"]
if not inf["vorhanden"]:
return {"ok": False, "error": inf["hinweis"]}
if not inf["schaltbar"]:
return {"ok": False, "not_switchable": True, "error": inf["hinweis"]}
auth = _fb_find_service(ip, "X_AVM-DE_Auth")
act = next(a for a in _fb_actions(ip, auth) if a["name"] == inf["aktion"])
arg = next(x for x in act["in"] if "enable" in x.lower())
on = str(params.get("enable")).lower() in ("1", "true", "an", "on", "ja")
return _fb_call(ip, user, pw, {"service": auth["short"], "action": act["name"],
"args": {arg: 1 if on else 0}, "confirmed": params.get("confirmed"),
"button_wait": params.get("button_wait")})
def _fb_backup_delete(params: dict) -> dict:
path = _fb_backup_path(params.get("file") or params.get("datei") or "")
if not path:
@@ -1763,6 +1845,10 @@ def _do_fritzbox(action: str, params: dict) -> dict:
return _fb_backup(ip, user, pw, params)
if action == "fritzbox.restore":
return _fb_restore(ip, user, pw, params)
if action == "fritzbox.second_factor":
return _fb_second_factor_info(ip, user, pw)
if action == "fritzbox.second_factor_set":
return _fb_second_factor_set(ip, user, pw, params)
if action == "fritzbox.hosts":
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
@@ -2679,7 +2765,7 @@ class Satellite:
# (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"))
needs_devices = (action in ("dial.launch", "fritzbox.backup_all") 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