fix(satellite): FritzBox-Tastenbestaetigung — Token im SOAP-Header mitschicken

An der Kellerbox kam nach dem Tastendruck "stopped" statt "authenticated".
Laut AVM "TR-064 Support – Authentication" (Kap. 6.4) muss der Token aus
X_AVM-DE_Auth SetConfig(start) bei GetState UND beim Wiederholen der
Aktion als <avm:token> im SOAP-Header stehen — ohne ihn ordnet die Box die
Bestaetigung nicht unserem Vorgang zu.

- _tr064(token=…) setzt den Header.
- GetState und die wiederholte Aktion laufen mit dem Token; Stop ebenso.
- Zustaende werden geloggt und erklaert (stopped/blocked/
  anotherauthprocess/failure); 867 (gesperrt, bis 60 min) und 868
  (beschaeftigt, ~2 min) mit verstaendlicher Meldung.

Getestet gegen eine nachgebaute Box, die sich wie in AVMs Doku verhaelt
(ohne Token "stopped", mit Token "authenticated").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:06:11 +01:00
co-authored by Claude Opus 5.5
parent 236e94da4a
commit 0a1e334169
+30 -9
View File
@@ -1213,7 +1213,7 @@ def _do_snmp_info(params: dict) -> dict:
# schlanker Reader. Braucht FritzBox-Login (Credential-Store, Typ 'fritzbox'). # schlanker Reader. Braucht FritzBox-Login (Credential-Store, Typ 'fritzbox').
def _tr064(ip: str, user: str, pw: str, service: str, control: str, def _tr064(ip: str, user: str, pw: str, service: str, control: str,
action: str, args: Optional[dict] = None) -> dict: action: str, args: Optional[dict] = None, token: str = "") -> dict:
"""Ein TR-064-SOAP-Call. Gibt {ok, fields|error}. fields = alle <NewX>-Tags.""" """Ein TR-064-SOAP-Call. Gibt {ok, fields|error}. fields = alle <NewX>-Tags."""
import requests import requests
from requests.auth import HTTPDigestAuth from requests.auth import HTTPDigestAuth
@@ -1221,7 +1221,14 @@ def _tr064(ip: str, user: str, pw: str, service: str, control: str,
envelope = ( envelope = (
'<?xml version="1.0"?>' '<?xml version="1.0"?>'
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ' '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>' 's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
# Zusatzbestaetigung: der Token aus X_AVM-DE_Auth SetConfig(start) MUSS
# bei GetState und beim Wiederholen der Aktion im SOAP-Header stehen
# (AVM "TR-064 Support – Authentication", Kap. 6.4) — sonst ordnet die
# Box den Tastendruck nicht zu und meldet "stopped".
+ (f'<s:Header><avm:token xmlns:avm="avm.de" s:mustUnderstand="1">{token}</avm:token></s:Header>'
if token else '')
+ '<s:Body>'
f'<u:{action} xmlns:u="{service}">{body}</u:{action}>' f'<u:{action} xmlns:u="{service}">{body}</u:{action}>'
'</s:Body></s:Envelope>' '</s:Body></s:Envelope>'
) )
@@ -1374,18 +1381,27 @@ def _fb_second_factor(ip: str, user: str, pw: str) -> dict:
if not st.get("ok"): if not st.get("ok"):
return {"ok": False, "error": f"Bestaetigung nicht startbar: {st.get('error')}"} return {"ok": False, "error": f"Bestaetigung nicht startbar: {st.get('error')}"}
methods = st["fields"].get("NewMethods", "") methods = st["fields"].get("NewMethods", "")
token = st["fields"].get("NewToken", "")
logger.info("[fritzbox] %s: warte bis %.0fs auf Bestaetigung am Geraet (%s)", ip, FB_BUTTON_WAIT_SEC, methods) logger.info("[fritzbox] %s: warte bis %.0fs auf Bestaetigung am Geraet (%s)", ip, FB_BUTTON_WAIT_SEC, methods)
deadline = time.time() + FB_BUTTON_WAIT_SEC deadline = time.time() + FB_BUTTON_WAIT_SEC
state = st["fields"].get("NewState", "") state = st["fields"].get("NewState", "")
reasons = {"stopped": "die Box hat den Vorgang beendet, ohne Bestaetigung (Zeit abgelaufen?)",
"blocked": "zu viele Versuche — die Box sperrt bis zu 60 Minuten",
"anotherauthprocess": "es laeuft schon eine Bestaetigung fuer einen anderen Benutzer",
"failure": "interner Fehler der Box", "disabled": "die Zusatzbestaetigung ist aus"}
while time.time() < deadline: while time.time() < deadline:
if state == "authenticated": if state == "authenticated":
return {"ok": True} logger.info("[fritzbox] %s: bestaetigt", ip)
if state in ("blocked", "failure", "stopped"): return {"ok": True, "token": token}
return {"ok": False, "error": f"Bestaetigung abgebrochen (Box meldet '{state}')."} if state in reasons and state != "disabled":
return {"ok": False, "error": f"Bestaetigung nicht erfolgt — Box meldet '{state}': {reasons[state]}."}
time.sleep(2) time.sleep(2)
g = _tr064(ip, user, pw, auth["type"], auth["control"], "GetState") g = _tr064(ip, user, pw, auth["type"], auth["control"], "GetState", token=token)
state = g.get("fields", {}).get("NewState", state) if g.get("ok") else state new = g.get("fields", {}).get("NewState", state) if g.get("ok") else state
_tr064(ip, user, pw, auth["type"], auth["control"], "SetConfig", {"NewAction": "stop"}) if new != state:
logger.info("[fritzbox] %s: Bestaetigung %s → %s", ip, state, new)
state = new
_tr064(ip, user, pw, auth["type"], auth["control"], "SetConfig", {"NewAction": "stop"}, token=token)
return {"ok": False, "timeout": True, return {"ok": False, "timeout": True,
"error": f"Timeout: Die Taste an der Box wurde nicht innerhalb von {FB_BUTTON_WAIT_SEC:.0f} s " "error": f"Timeout: Die Taste an der Box wurde nicht innerhalb von {FB_BUTTON_WAIT_SEC:.0f} s "
f"gedrueckt — abgebrochen, nichts ausgefuehrt." f"gedrueckt — abgebrochen, nichts ausgefuehrt."
@@ -1426,7 +1442,12 @@ def _fb_call(ip: str, user: str, pw: str, params: dict) -> dict:
sf = _fb_second_factor(ip, user, pw) sf = _fb_second_factor(ip, user, pw)
if not sf.get("ok"): if not sf.get("ok"):
return sf return sf
r = _tr064(ip, user, pw, svc["type"], svc["control"], action, args) # Gleiche Aktion, gleiche Werte — jetzt mit dem Token im SOAP-Header
r = _tr064(ip, user, pw, svc["type"], svc["control"], action, args, token=sf.get("token", ""))
if not r.get("ok") and r.get("upnp_error") == "867":
return {"ok": False, "error": "Die Box sperrt die Bestaetigung (zu viele Versuche) — bis zu 60 Minuten warten."}
if not r.get("ok") and r.get("upnp_error") == "868":
return {"ok": False, "error": "Die Box ist mit einer anderen Bestaetigung beschaeftigt — in etwa 2 Minuten nochmal."}
if not r.get("ok"): if not r.get("ok"):
return r return r
# Geheimnisse, die die Box auf Lese-Anfragen herausgibt (WLAN-Schluessel, # Geheimnisse, die die Box auf Lese-Anfragen herausgibt (WLAN-Schluessel,