feat(satellite): FritzBox komplett steuern — describe/call/wlan ueber TR-064

Statt einzelner fest programmierter Befehle nutzt der Satellit die
Selbstbeschreibung der Box (tr64desc.xml + SCPD je Dienst, ohne Anmeldung
lesbar): was DIESE Firmware kann, steht dort — keine Tabellen pro Modell.

- fritzbox.describe: alle TR-064-Aktionen der Box, je mit [liest]/
  [aendert]/[GEFAEHRLICH]; filter grenzt ein (WLAN, Dect, Hosts …).
- fritzbox.call: beliebige Aktion (service kurz "WLANConfiguration:3",
  args ohne "New"-Praefix moeglich). Alles ausser Get… braucht
  confirmed=true, sonst needs_confirmation — ARIA muss vorher fragen.
- fritzbox.wlan: alle WLANs inkl. Gast (index/ssid/an), schalten mit
  index + enable (+ confirmed).
- FritzBox per Geraetename ("Fritzbox Keller") statt IP ansprechbar.
- Allowlist kennt Platzhalter "fritzbox.*" (Default + .env.example).
- Brain: Tool-Beschreibung mit Ablauf (describe → call) und Freigabe-Regel,
  fritzbox.* mit 60 s Timeout.

Getestet gegen eine nachgebaute TR-064-Box: describe/filter, lesen,
schreiben ohne/mit Freigabe (ohne kommt nichts an der Box an), WLAN
schalten, Namensaufloesung, Allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:11:58 +01:00
co-authored by Claude Opus 5.5
parent 0bf00f9fc4
commit 116a947a4a
3 changed files with 204 additions and 8 deletions
+5 -1
View File
@@ -40,6 +40,10 @@ CONTROL_ENABLED=true
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
# 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.* 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)
# adb.connect ADB-Verbindung aufbauen (params: ip)
@@ -52,7 +56,7 @@ CONTROL_ENABLED=true
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.*,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
+184 -5
View File
@@ -135,7 +135,7 @@ CONTROL_ALLOWLIST = [
a.strip() for a in
os.environ.get("CONTROL_ALLOWLIST",
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,"
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,"
"snmp.ports,snmp.info,fritzbox.*,ssh.exec,"
"adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status").split(",")
if a.strip()
]
@@ -815,11 +815,19 @@ def _tunnel_allowed(ip: str, port: int, devices: list[dict]) -> tuple[bool, str]
# ─── Control ────────────────────────────────────────────────────────
def _action_allowed(action: str) -> bool:
"""Allowlist mit Platzhalter: "fritzbox.*" erlaubt alle fritzbox-Aktionen."""
for a in CONTROL_ALLOWLIST:
if a == action or (a.endswith(".*") and action.startswith(a[:-1])):
return True
return False
async def _control(action: str, params: dict, devices: list[dict]) -> dict:
"""Fuehrt eine Steuer-Aktion aus. Guards: CONTROL_ENABLED + Allowlist."""
if not CONTROL_ENABLED:
return {"ok": False, "error": "Steuerung ist an diesem Satelliten deaktiviert (CONTROL_ENABLED=false)."}
if action not in CONTROL_ALLOWLIST:
if not _action_allowed(action):
return {"ok": False, "error": f"Aktion '{action}' nicht erlaubt (Allowlist: {', '.join(CONTROL_ALLOWLIST)})."}
logger.info("[control] %s params=%s", action, {k: str(v)[:60] for k, v in (params or {}).items()})
loop = asyncio.get_event_loop()
@@ -838,8 +846,11 @@ 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 in ("fritzbox.info", "fritzbox.hosts"):
return await loop.run_in_executor(None, _do_fritzbox, action, params)
if action.startswith("fritzbox."):
# Geraetename ("Fritzbox Keller") → IP, wie bei den anderen Aktionen
t = _snmp_target(params)
ip = _resolve_target(t, devices) or t
return await loop.run_in_executor(None, _do_fritzbox, action, {**params, "ip": ip})
if action == "ssh.exec":
return await loop.run_in_executor(None, _do_ssh, params)
if action == "adb.devices":
@@ -1197,6 +1208,165 @@ def _tr064(ip: str, user: str, pw: str, service: str, control: str,
return {"ok": True, "fields": fields}
# ─── FritzBox generisch: alles, was die Firmware per TR-064 anbietet ───
# Jede Box beschreibt sich selbst (tr64desc.xml → Dienste, je Dienst eine
# SCPD-Datei → Aktionen mit Argumenten). Beides ist ohne Anmeldung lesbar.
# Damit muss niemand pro Modell/Firmware Befehle pflegen: fritzbox.describe
# zeigt, was DIESE Box kann, fritzbox.call ruft es auf.
_FB_CACHE: dict = {} # (ip, "desc"|scpd-url) → (ts, data)
_FB_CACHE_SEC = 3600
# Schreibende Aktionen, die besonders weh tun koennen
_FB_DANGEROUS = ("Reboot", "FactoryReset", "DoUpdate", "DoManualUpdate", "SetConfigFile",
"AddPortMapping", "DeletePortMapping", "AddPinhole", "SetPassword",
"SetSecurityKeys", "SetDefaultWEPKeyIndex", "SetUser", "DeleteUser")
def _fb_get(ip: str, path: str) -> str:
import requests
key = (ip, path)
hit = _FB_CACHE.get(key)
if hit and time.time() - hit[0] < _FB_CACHE_SEC:
return hit[1]
r = requests.get(f"http://{ip}:49000{path}", timeout=HTTP_TIMEOUT_SEC)
r.raise_for_status()
_FB_CACHE[key] = (time.time(), r.text)
return r.text
def _fb_services(ip: str) -> list:
"""[{type, short, control, scpd}] aus tr64desc.xml."""
xml = _fb_get(ip, "/tr64desc.xml")
out = []
for m in re.finditer(r"<service>(.*?)</service>", xml, re.DOTALL):
blk = m.group(1)
def _t(tag):
mm = re.search(rf"<{tag}>(.*?)</{tag}>", blk, re.DOTALL)
return mm.group(1).strip() if mm else ""
stype = _t("serviceType")
if stype:
# "urn:dslforum-org:service:WLANConfiguration:3" → "WLANConfiguration:3"
short = ":".join(stype.split(":")[-2:])
out.append({"type": stype, "short": short, "control": _t("controlURL"), "scpd": _t("SCPDURL")})
return out
def _fb_actions(ip: str, svc: dict) -> list:
"""[{name, in:[...], out:[...]}] aus der SCPD-Datei eines Dienstes."""
xml = _fb_get(ip, svc["scpd"])
out = []
for m in re.finditer(r"<action>(.*?)</action>", xml, re.DOTALL):
blk = m.group(1)
name = re.search(r"<name>(.*?)</name>", blk)
if not name:
continue
args_in, args_out = [], []
for a in re.finditer(r"<argument>(.*?)</argument>", blk, re.DOTALL):
an = re.search(r"<name>(.*?)</name>", a.group(1))
ad = re.search(r"<direction>(.*?)</direction>", a.group(1))
if an:
(args_in if (ad and ad.group(1).strip() == "in") else args_out).append(an.group(1).strip())
out.append({"name": name.group(1).strip(), "in": args_in, "out": args_out})
return out
def _fb_is_read(action: str) -> bool:
"""Lesend = Get… (auch X_AVM-DE_Get…). Alles andere aendert etwas."""
base = action[len("X_AVM-DE_"):] if action.startswith("X_AVM-DE_") else action
return base.startswith("Get")
def _fb_find_service(ip: str, service: str) -> Optional[dict]:
"""Voller urn, "WLANConfiguration:3" oder nur "WLANConfiguration" (= :1)."""
want = (service or "").strip()
svcs = _fb_services(ip)
for s_ in svcs:
if want in (s_["type"], s_["short"]):
return s_
if ":" not in want:
return next((s_ for s_ in svcs if s_["short"].split(":")[0] == want), None)
return None
def _fb_describe(ip: str, params: dict) -> dict:
flt = str(params.get("filter") or "").strip().lower()
try:
svcs = _fb_services(ip)
except Exception as exc:
return {"ok": False, "error": f"Selbstbeschreibung der Box (:49000/tr64desc.xml) nicht lesbar: {exc}"}
lines = []
for svc in svcs:
try:
acts = _fb_actions(ip, svc)
except Exception:
continue
for a in acts:
text = f"{svc['short']} {a['name']}"
if flt and flt not in text.lower():
continue
kind = "liest" if _fb_is_read(a["name"]) else (
"AENDERT, GEFAEHRLICH" if any(d in a["name"] for d in _FB_DANGEROUS) else "aendert")
lines.append(f"{svc['short']} · {a['name']}({', '.join(a['in'])})"
+ (f" → {', '.join(a['out'])}" if a["out"] and flt else "") + f" [{kind}]")
if not lines:
return {"ok": True, "result": {"ip": ip, "hinweis": f"Keine Aktion passt zu '{flt}'." if flt else "Keine Aktionen gefunden."}}
limit = 120
return {"ok": True, "result": {
"ip": ip, "count": len(lines), "aktionen": lines[:limit],
"hinweis": ("Aufrufen mit fritzbox.call {service, action, args}. Fuer Ausgabefelder 'filter' setzen."
+ (f" Gekuerzt auf {limit} — mit 'filter' eingrenzen (z.B. 'WLAN', 'Dect', 'Hosts')." if len(lines) > limit else ""))}}
def _fb_call(ip: str, user: str, pw: str, params: dict) -> dict:
action = str(params.get("action") or "").strip()
svc = _fb_find_service(ip, str(params.get("service") or ""))
if not action or not svc:
return {"ok": False, "error": "service + action noetig (siehe fritzbox.describe), z.B. "
"service='WLANConfiguration:3', action='GetInfo'."}
if not _fb_is_read(action) and params.get("confirmed") not in (True, "true"):
danger = any(d in action for d in _FB_DANGEROUS)
return {"ok": False, "needs_confirmation": True, "error": (
f"'{svc['short']} {action}' AENDERT etwas an der FritzBox"
+ (" und ist GEFAEHRLICH (Neustart/Freigabe/Passwort o.ae.)" if danger else "")
+ ". Erst Stefan fragen, was genau passiert; nur nach seinem ausdruecklichen Ja "
"erneut mit confirmed=true aufrufen.")}
args = params.get("args") if isinstance(params.get("args"), dict) else {}
args = {(k if k.startswith("New") else "New" + k): v for k, v in args.items()}
logger.info("[fritzbox] %s %s %s args=%s", ip, svc["short"], action, list(args))
r = _tr064(ip, user, pw, svc["type"], svc["control"], action, args)
if not r.get("ok"):
return r
return {"ok": True, "result": {"ip": ip, "service": svc["short"], "action": action, **r["fields"]}}
def _fb_wlan(ip: str, user: str, pw: str, params: dict) -> dict:
"""Alle WLANs der Box (2,4/5/6 GHz, Gast) mit SSID und an/aus; mit
index + enable schalten (braucht confirmed=true)."""
wl = sorted((s_ for s_ in _fb_services(ip) if s_["short"].startswith("WLANConfiguration:")),
key=lambda s_: int(s_["short"].split(":")[1]))
if "enable" in params and params.get("index") is not None:
target = next((s_ for s_ in wl if s_["short"] == f"WLANConfiguration:{params['index']}"), None)
if not target:
return {"ok": False, "error": f"WLAN {params['index']} gibt es nicht (vorhanden: 1–{len(wl)})."}
on = str(params.get("enable")).lower() in ("1", "true", "an", "on", "ja")
return _fb_call(ip, user, pw, {"service": target["short"], "action": "SetEnable",
"args": {"Enable": 1 if on else 0},
"confirmed": params.get("confirmed")})
out = []
for i, s_ in enumerate(wl, 1):
r = _tr064(ip, user, pw, s_["type"], s_["control"], "GetInfo")
f = r.get("fields", {}) if r.get("ok") else {}
w = {"index": int(s_["short"].split(":")[1]), "ssid": f.get("NewSSID", ""),
"an": f.get("NewEnable") == "1", "status": f.get("NewStatus", ""),
"kanal": f.get("NewChannel", ""), "standard": f.get("NewStandard", ""),
"band": f.get("NewX_AVM-DE_FrequencyBand", "")}
if not r.get("ok"):
w["fehler"] = r.get("error")
out.append({k: v for k, v in w.items() if v not in ("", None)})
return {"ok": True, "result": {"ip": ip, "wlans": out,
"hinweis": "Das letzte WLAN ist meist das Gast-WLAN. Schalten: index + enable (+ confirmed=true nach Stefans Ja)."}}
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
@@ -1205,6 +1375,8 @@ def _do_fritzbox(action: str, params: dict) -> dict:
ip = _snmp_target(params)
if not ip:
return {"ok": False, "error": "ip/host erforderlich."}
if action == "fritzbox.describe": # braucht keine Anmeldung
return _fb_describe(ip, 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 "")
@@ -1213,6 +1385,11 @@ def _do_fritzbox(action: str, params: dict) -> dict:
"Kein FritzBox-Login hinterlegt. In der Geraeteliste Credentials "
"(Typ 'fritzbox') fuer diese IP setzen."}
if action == "fritzbox.call":
return _fb_call(ip, user, pw, params)
if action == "fritzbox.wlan":
return _fb_wlan(ip, user, pw, params)
if action == "fritzbox.hosts":
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
"/upnp/control/hosts", "X_AVM-DE_GetHostListPath")
@@ -2189,7 +2366,9 @@ 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 == "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({