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