feat(satellite): APK-Rollout per ADB — Fire TV & Android aus der Ferne bespielen

Der Satellit kann Android-Geraete im LAN erkennen und die Agent-APK ausrollen,
statt sie von Hand aufs Geraet zu kopieren.

- satellite.py: adb.devices/connect/install/shell. _apk_fetch laedt die APK per
  HTTP (gecached in /data/apk, sha256 optional) statt sie durchs RVS zu schicken —
  der RVS ist ein BROADCAST-Relay (jede base64-APK ginge an alle Clients, und das
  Satelliten-max_size liegt bei 8 MB). adb.install ist selbstheilend (connect ->
  install -r) und uebersetzt die typischen Fehler in klare Ansagen (unauthorized,
  Signatur-Mismatch, Speicher voll). Imports lokal in den Funktionen (Hausmuster).
- Discovery: mDNS-Typen _amzn-wplay/_adb-tls-connect/_androidtvremote2, _mdns_kind
  -> firetv/android-adb/androidtv, plus _enrich_adb (TCP-Probe 5555) -> adbCapable.
- Dockerfile: adb; compose: HOME=/data (ADB-Key persistent, sonst fragt das Geraet
  nach jedem Rebuild neu) + ANDROID_ADB_SERVER_PORT=5038 (Host-Kollision).
- .env.example: neue Aktionen + Warnung, dass die Allowlist den Code-Default
  ueberschreibt (sonst lehnt ein bestehender Satellit adb.* ab).
- Brain: adb.* in der satellite_command-Beschreibung; WICHTIG Timeout 200s fuer
  adb.*/ssh.exec — der Bridge-Default von 20s haette jede Installation abgewuergt.
- Diagnostic: sat_command (Browser->RVS) und sat_result (RVS->Browser) gab es
  bisher GAR NICHT; beide ergaenzt. 📦-Button je ADB-faehigem Geraet + Rollout-
  Modal mit Status/Log, ADB-Tag in der Geraeteliste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 19:17:53 +02:00
co-authored by Claude Opus 4.8
parent e15f7a5bc0
commit 47735642f2
7 changed files with 401 additions and 7 deletions
+262 -3
View File
@@ -134,10 +134,20 @@ 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").split(",")
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,"
"adb.devices,adb.connect,adb.install,adb.shell").split(",")
if a.strip()
]
# adb.*: Android-Geraete (v.a. Fire TV) im LAN per Netzwerk-Debugging erreichen.
# install dauert lange (APK laden + uebertragen + installieren) -> grosszuegiger
# Timeout. APK-Cache in /data (persistentes Volume), damit N Sticks nur EINEN
# Download brauchen.
ADB_PORT = int(os.environ.get("ADB_PORT", "5555") or "5555")
ADB_TIMEOUT_SEC = float(os.environ.get("ADB_TIMEOUT_SEC", "180") or "180")
APK_CACHE_DIR = os.environ.get("APK_CACHE_DIR", "/data/apk")
APK_MAX_BYTES = int(os.environ.get("APK_MAX_BYTES", str(64 * 1024 * 1024)) or str(64 * 1024 * 1024))
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
@@ -269,6 +279,9 @@ MDNS_TYPES = [
"_smb._tcp.local.", # NAS / Fileshares
"_workstation._tcp.local.", # generische Hosts
"_http._tcp.local.", # Web-UIs (Router, NAS, IoT)
"_amzn-wplay._tcp.local.", # Amazon Whisperplay — zuverlaessigster Fire-TV-Marker
"_adb-tls-connect._tcp.local.", # Android 11+ Wireless Debugging
"_androidtvremote2._tcp.local.", # Android TV Remote
]
CAPABILITIES = ["discover"]
@@ -404,6 +417,8 @@ def _mdns_kind(service_type: str) -> str:
"_spotify-connect": "spotify", "_sonos": "sonos", "_hap": "homekit",
"_printer": "printer", "_ipp": "printer", "_smb": "fileshare",
"_workstation": "host", "_http": "web",
"_amzn-wplay": "firetv", "_adb-tls-connect": "android-adb",
"_androidtvremote2": "androidtv",
}
for k, v in m.items():
if service_type.startswith(k):
@@ -565,6 +580,14 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
return await loop.run_in_executor(None, _do_fritzbox, action, params)
if action == "ssh.exec":
return await loop.run_in_executor(None, _do_ssh, params)
if action == "adb.devices":
return await loop.run_in_executor(None, _do_adb_devices, params)
if action == "adb.connect":
return await loop.run_in_executor(None, _do_adb_connect, params)
if action == "adb.install":
return await loop.run_in_executor(None, _do_adb_install, params)
if action == "adb.shell":
return await loop.run_in_executor(None, _do_adb_shell, params)
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
except Exception as exc:
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
@@ -1046,6 +1069,205 @@ def _do_ssh(params: dict) -> dict:
"stderr": err[:4000], **win}}
# ─── ADB: Android-Geraete im LAN (Fire TV Sticks) ───────────────────
def _adb_run(args: list, timeout: float = 0) -> tuple:
"""Fuehrt adb aus. Gibt (rc, stdout, stderr). Muster wie _snmp_run."""
import subprocess
try:
r = subprocess.run(["adb"] + args, capture_output=True, text=True,
timeout=timeout or ADB_TIMEOUT_SEC)
return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
except FileNotFoundError:
return 127, "", ("adb fehlt im Container — 'adb' ins satellite/Dockerfile "
"aufnehmen und neu bauen.")
except subprocess.TimeoutExpired:
return 124, "", f"adb-Timeout nach {timeout or ADB_TIMEOUT_SEC:.0f}s."
def _adb_target(params: dict) -> str:
"""Ziel als ip:port (Port default 5555)."""
host = _snmp_target(params)
if not host:
return ""
return host if ":" in host else f"{host}:{int(params.get('port') or ADB_PORT)}"
def _adb_connect(target: str) -> tuple:
"""Verbindet zum Geraet. Gibt (ok, meldung)."""
rc, out, err = _adb_run(["connect", target], timeout=25)
txt = f"{out} {err}".strip()
low = txt.lower()
if "connected to" in low: # connected / already connected
return True, txt
if "unauthorized" in low:
return False, ("Geraet hat die Verbindung nicht autorisiert. Auf dem "
"Bildschirm des Geraets den Dialog 'USB-Debugging zulassen?' "
"bestaetigen (Haken bei 'Immer erlauben').")
if rc == 127 or "adb fehlt" in low:
return False, txt
return False, (txt or "Verbindung fehlgeschlagen") + \
" — ADB-Debugging aktiv? (Fire TV: Einstellungen > Mein Fire TV > " \
"Entwickleroptionen > ADB-Debugging)"
def _adb_state(target: str) -> str:
"""'device' | 'unauthorized' | 'offline' | '' (unbekannt)."""
rc, out, _ = _adb_run(["devices"], timeout=20)
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] == target:
return parts[1]
return ""
def _do_adb_devices(params: dict) -> dict:
"""Listet die dem Satelliten bekannten ADB-Geraete."""
rc, out, err = _adb_run(["devices", "-l"], timeout=20)
if rc not in (0,):
return {"ok": False, "error": err or "adb devices fehlgeschlagen"}
devices = []
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2:
devices.append({"target": parts[0], "state": parts[1],
"info": " ".join(parts[2:])})
return {"ok": True, "result": {"devices": devices, "count": len(devices)}}
def _do_adb_connect(params: dict) -> dict:
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
return {"ok": True, "result": {"target": target, "state": _adb_state(target),
"message": msg}}
def _do_adb_shell(params: dict) -> dict:
"""Shell-Kommando auf dem Android-Geraet (Diagnose, z.B. 'pm list packages')."""
target = _adb_target(params)
cmd = (params.get("cmd") or params.get("command") or "").strip()
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
if not cmd:
return {"ok": False, "error": "cmd erforderlich."}
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
rc, out, err = _adb_run(["-s", target, "shell", cmd], timeout=60)
win = _window_text(out, params)
return {"ok": rc == 0, "result": {"target": target, "exit_code": rc,
"stderr": err[:2000], **win},
**({} if rc == 0 else {"error": err[:500] or f"exit {rc}"})}
def _apk_fetch(params: dict) -> tuple:
"""Besorgt die APK lokal. Gibt (pfad, fehler).
Quelle: 'url' (HTTP-Pull, gecached in APK_CACHE_DIR) ODER 'apk_base64'.
HTTP-Pull ist der Normalweg — der RVS ist ein Broadcast-Relay, eine APK
als base64 ginge an JEDEN Client im Raum."""
import base64
import hashlib
import requests
os.makedirs(APK_CACHE_DIR, exist_ok=True)
url = (params.get("url") or "").strip()
b64 = params.get("apk_base64")
want_sha = (params.get("sha256") or "").strip().lower()
if b64:
try:
data = base64.b64decode(b64)
except Exception as exc:
return "", f"apk_base64 ungueltig: {exc}"
elif url:
digest = hashlib.sha256(url.encode()).hexdigest()[:16]
cached = os.path.join(APK_CACHE_DIR, f"{digest}.apk")
if os.path.isfile(cached) and os.path.getsize(cached) > 0:
return cached, ""
try:
headers = {}
if params.get("token"):
headers["Authorization"] = f"token {params['token']}"
with requests.get(url, stream=True, timeout=60, headers=headers,
verify=not params.get("insecure")) as r:
r.raise_for_status()
total = 0
tmp = cached + ".part"
with open(tmp, "wb") as f:
for chunk in r.iter_content(65536):
total += len(chunk)
if total > APK_MAX_BYTES:
f.close(); os.remove(tmp)
return "", f"APK groesser als {APK_MAX_BYTES // 1024 // 1024} MB."
f.write(chunk)
os.replace(tmp, cached)
except Exception as exc:
return "", f"APK-Download fehlgeschlagen: {exc}"
data = None
else:
return "", "url ODER apk_base64 erforderlich."
if data is not None:
if len(data) > APK_MAX_BYTES:
return "", f"APK groesser als {APK_MAX_BYTES // 1024 // 1024} MB."
path = os.path.join(APK_CACHE_DIR,
hashlib.sha256(data).hexdigest()[:16] + ".apk")
with open(path, "wb") as f:
f.write(data)
cached = path
if want_sha:
h = hashlib.sha256()
with open(cached, "rb") as f:
for blk in iter(lambda: f.read(65536), b""):
h.update(blk)
if h.hexdigest().lower() != want_sha:
return "", "sha256 stimmt nicht — Download verworfen."
return cached, ""
def _do_adb_install(params: dict) -> dict:
"""Rollt eine APK auf ein Android-Geraet aus (Fire TV & Co.).
params: {ip|host|device, url|apk_base64, sha256?, port?, downgrade?, timeout?}
Selbstheilend: verbindet, installiert, gibt den Status zurueck."""
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
apk, err = _apk_fetch(params)
if err:
return {"ok": False, "error": err}
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
state = _adb_state(target)
if state and state != "device":
return {"ok": False, "error": f"Geraet im Zustand '{state}' — "
"bei 'unauthorized' den Dialog auf dem Bildschirm bestaetigen."}
args = ["-s", target, "install", "-r"] # -r = Update statt Neuinstallation
if params.get("downgrade"):
args.append("-d")
args.append(apk)
rc, out, err2 = _adb_run(args, timeout=float(params.get("timeout") or ADB_TIMEOUT_SEC))
txt = f"{out} {err2}".strip()
size_mb = round(os.path.getsize(apk) / 1024 / 1024, 1)
if rc == 0 and "success" in txt.lower():
return {"ok": True, "result": {"target": target, "apk_mb": size_mb,
"message": f"APK installiert ({size_mb} MB)"}}
low = txt.lower()
if "signatures do not match" in low or "update_incompatible" in low:
return {"ok": False, "error": "Signatur passt nicht zur installierten Version — "
"die alte App muss zuerst deinstalliert werden (adb.shell "
"'pm uninstall <paket>')."}
if "insufficient" in low or "no space" in low:
return {"ok": False, "error": "Nicht genug Speicher auf dem Geraet."}
return {"ok": False, "error": (txt or f"Installation fehlgeschlagen (exit {rc})")[:500]}
def _to_int(s: str):
try:
return int(str(s).strip())
@@ -1113,6 +1335,13 @@ def _parse_http_headers(text: str) -> dict:
return headers
def _looks_amazon(dev: dict) -> bool:
"""Fire TV? Amazon-Hersteller oder Modell-Praefix AFT (AFTMM, AFTKA, ...)."""
blob = f"{dev.get('manufacturer', '')} {dev.get('name', '')}".lower()
model = str(dev.get("model", "")).upper()
return "amazon" in blob or "fire tv" in blob or model.startswith("AFT")
def _xml_tag(xml: str, tag: str) -> str:
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml, re.IGNORECASE | re.DOTALL)
return m.group(1).strip() if m else ""
@@ -1147,10 +1376,12 @@ class Satellite:
self._devices = _merge_devices(mdns, ssdp, arp)
if SNMP_DISCOVERY:
await self._enrich_snmp(self._devices)
await self._enrich_adb(self._devices)
self._devices_ts = time.time()
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp)
n_adb = sum(1 for d in self._devices if d.get("adbCapable"))
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d, adb=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp, n_adb)
finally:
self._scanning = False
return self._devices
@@ -1181,6 +1412,34 @@ class Satellite:
await asyncio.gather(*(_one(d) for d in devices))
async def _enrich_adb(self, devices: list) -> None:
"""Prueft parallel, welche Geraete ADB-Netzwerk-Debugging offen haben
(TCP 5555). Damit sieht ARIA/die Diagnostic sofort, auf welche Geraete
(v.a. Fire TV Sticks) sich eine APK ausrollen laesst."""
sem = asyncio.Semaphore(16)
async def _one(dev: dict) -> None:
ip = dev.get("ip") or ""
if not ip:
return
async with sem:
try:
fut = asyncio.open_connection(ip, ADB_PORT)
reader, writer = await asyncio.wait_for(fut, timeout=1.5)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
except Exception:
return
dev["adbCapable"] = True
dev["adbPort"] = ADB_PORT
if dev.get("type", "") in ("", "host", "unknown", "dial"):
dev["type"] = "firetv" if _looks_amazon(dev) else "android"
await asyncio.gather(*(_one(d) for d in devices))
async def _send(self, message: dict) -> None:
if self.ws is None:
return