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:
+262
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user