diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2a886..a21f3aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ Alle Γ„nderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c --- +## [0.2.3.0] β€” 2026-07-19 β€” Satelliten: ARIAs Augen & HΓ€nde in fremden Netzen πŸ›°οΈ + +### HinzugefΓΌgt + +**Neuer eigenstΓ€ndiger Container `satellite/`** β€” ein Außenposten, den du in einem beliebigen Netz (BΓΌro, Werkstatt …) deployst. Er verbindet sich als RVS-Client in deinen Raum und gibt ARIA Zugriff auf **genau dieses Netz**, ohne dass der Haupt-Stack dort steht. +- **Entdeckung (Info):** mDNS/Zeroconf (Chromecast, AirPlay, Sonos, Drucker, NAS …), SSDP/UPnP + **DIAL** (Smart-TVs, Fire TV), ARP-Tabelle (rohe Hosts) β†’ Live-Inventar. +- **Steuerung (mit Guards):** **DIAL-App-Launch** (z.B. β€žARIA, spiel YouTube-Video X auf dem BΓΌro-Stick" β†’ Fire TV), **Wake-on-LAN**, generisches **HTTP**. Nur wenn `CONTROL_ENABLED=true`, nur Aktionen aus der `CONTROL_ALLOWLIST`, alles geloggt, read-only per `.env` abschaltbar. Reagiert nur auf den eigenen RVS-Raum (Token). Keine offenen Ports. +- **Adressierung** ΓΌber `SATELLITE_LOCATION` (z.B. β€žBΓΌro") β€” mehrere Satelliten im selben Raum, jeder mit eigenem Namen. + +**End-to-end verdrahtet:** +- `satellite/`: eigener Stack (`docker compose` mit `network_mode: host`), `.env.example`, README. +- RVS: neue Message-Typen `sat_hello / sat_discover / sat_devices / sat_command / sat_result`. +- Bridge: Satelliten-Registry (`sat_hello`) + Future-Relay (`/internal/satellite`, `/internal/satellite-list`) analog zum flux-Muster. +- Brain: Tools `satellite_list`, `satellite_devices`, `satellite_command` + Seed-Regel, die ARIA den Ablauf beibringt (erst list, dann devices, dann command). + +### Deploy +Satellit im Ziel-Netz: `cd satellite && cp .env.example .env && docker compose up -d --build`. Haupt-Stack: `git pull && docker compose up -d --build brain bridge` + RVS-Stack `up -d --build`. Kein APK-Rebuild. + +--- + ## [0.2.2.3] β€” 2026-07-17 β€” ARIA liest andere Projekt-Chats wirklich (volle Historie) ### Behoben diff --git a/README.md b/README.md index 1f9a803..23280be 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ ARIA hat zwei Rollen: | RVS | Rechenzentrum | `cd rvs && docker compose up -d` | | ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` | | Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` | +| Satellit(en) πŸ›°οΈ (optional) | Fremdes Netz (BΓΌro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` | | Android App | Stefans Handy | APK installieren (Auto-Update via RVS) | > Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU, diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 4e850dc..d91b9a6 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1105,6 +1105,62 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "satellite_list", + "description": ( + "Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, " + "z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / " + "dial.launch / wol / http). Nutze das ZUERST, wenn Stefan etwas 'im " + "Buero' / 'im Netz X' / 'auf dem dort' machen will β€” so weisst " + "Du welche Netze erreichbar sind." + ), + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "satellite_devices", + "description": ( + "Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, " + "Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches " + "Geraet gemeint ist, BEVOR Du satellite_command aufrufst." + ), + "parameters": { + "type": "object", + "properties": { + "satellite": {"type": "string", "description": "Satelliten-Name/Location/ID (z.B. 'Buero')."}, + }, + "required": ["satellite"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "satellite_command", + "description": ( + "Fuehrt eine Aktion auf einem Geraet im Netz eines Satelliten aus. " + "Beispiel YouTube-Video auf Fire TV: action='dial.launch', " + "device='Fire TV', params={'app':'YouTube','v':''}. Weitere " + "Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, " + "'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks. " + "Geht nur, wenn der Satellit Steuerung erlaubt (siehe satellite_list)." + ), + "parameters": { + "type": "object", + "properties": { + "satellite": {"type": "string", "description": "Satelliten-Name/Location/ID."}, + "device": {"type": "string", "description": "Geraet (Name/ID/IP) aus satellite_devices."}, + "action": {"type": "string", "description": "z.B. 'dial.launch', 'wol', 'http.get'."}, + "params": {"type": "object", "description": "Aktions-Parameter, z.B. {'app':'YouTube','v':''}."}, + }, + "required": ["satellite", "action"], + }, + }, + }, ] @@ -1938,6 +1994,70 @@ class Agent: return [] return out[-limit:] + def _dispatch_satellite(self, name: str, arguments: dict) -> str: + """satellite_list / satellite_devices / satellite_command β€” geht via + Bridge (/internal/satellite*) β†’ RVS β†’ Satellit. Muster wie flux_generate.""" + def _post(path: str, body: dict, timeout: float) -> dict: + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(f"{BRIDGE_URL}{path}", data=data, method="POST", + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8", "ignore")) + try: + if name == "satellite_list": + result = _post("/internal/satellite-list", {}, 10) + sats = result.get("satellites") or [] + if not sats: + return ("Gerade ist kein Satellit online. (Ein Satellit ist ein " + "Aussenposten-Container in einem fremden Netz, der sich mit " + "RVS verbindet und Dir dort Geraete zeigt + steuerbar macht.)") + lines = [] + for s in sats: + status = "online" if s.get("online") else "offline" + caps = ", ".join(s.get("caps") or []) or "nur beobachten" + lines.append(f"- {s.get('location')} (id={s.get('id')}, {status}; kann: {caps})") + return "Satelliten / erreichbare Netze:\n" + "\n".join(lines) + + if name == "satellite_devices": + sat = (arguments.get("satellite") or "").strip() + if not sat: + return "FEHLER: satellite ist Pflicht." + result = _post("/internal/satellite", {"op": "discover", "satellite": sat}, 30) + if result.get("error"): + return f"FEHLER: {result['error']}" + devices = result.get("devices") or [] + if not devices: + return f"Im Netz von '{sat}' wurden keine Geraete gefunden (oder der Satellit ist offline)." + lines = [] + for d in devices[:40]: + tags = [] + if d.get("model"): + tags.append(d["model"]) + if d.get("dialAppUrl"): + tags.append("DIAL/App-Launch") + if d.get("mac"): + tags.append(d["mac"]) + extra = f" ({'; '.join(tags)})" if tags else "" + lines.append(f"- {d.get('name')} [{d.get('type')}] {d.get('ip', '')}{extra} id={d.get('id')}") + return f"Geraete im Netz '{result.get('location', sat)}':\n" + "\n".join(lines) + + # satellite_command + sat = (arguments.get("satellite") or "").strip() + action = (arguments.get("action") or "").strip() + if not sat or not action: + return "FEHLER: satellite und action sind Pflicht." + params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {} + result = _post("/internal/satellite", { + "op": "command", "satellite": sat, + "device": arguments.get("device") or "", "action": action, "params": params, + }, 30) + if result.get("ok"): + r = result.get("result") + return f"OK β€” {r}" if r else "OK β€” Aktion ausgefuehrt." + return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}" + except Exception as exc: + return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}" + def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str: """Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck. Niemals werfen β€” Fehler werden als Text-Resultat reportet damit Claude @@ -2675,6 +2795,8 @@ class Agent: f"Editor + Desktop erscheinen in der App. Code-Dateien unter " f"/shared/projects/{active_id}/ schreiben, damit sie im Editor auftauchen.") return f"OK β€” Projekt '{updated['name']}' ist wieder ein normaler Chat." + if name in ("satellite_list", "satellite_devices", "satellite_command"): + return self._dispatch_satellite(name, arguments) return f"Unbekanntes Tool: {name}" except Exception as exc: logger.exception("Tool '%s' fehlgeschlagen", name) diff --git a/aria-brain/seed_rules.py b/aria-brain/seed_rules.py index b867dca..a6fb14a 100644 --- a/aria-brain/seed_rules.py +++ b/aria-brain/seed_rules.py @@ -437,6 +437,37 @@ SEED_RULES: List[dict] = [ "wie gewohnt einfach machen, nicht moralisieren." ), }, + { + "migration_key": "seed/architecture/satellites-remote-networks", + "type": "rule", + "title": "Satelliten: Augen + Haende in fremden Netzen (Buero etc.)", + "category": "architektur", + "content": ( + "Ein SATELLIT ist ein eigenstaendiger Container in einem FREMDEN Netz " + "(z.B. Buero, Werkstatt), der sich mit RVS verbindet und Dir dort Geraete " + "zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack " + "NICHT steht.\n" + "\n" + "Wenn Stefan etwas 'im Buero' / 'im Netz X' / 'auf dem dort' will:\n" + " 1. `satellite_list` β€” welche Satelliten/Netze sind online + was koennen sie.\n" + " 2. `satellite_devices(satellite='Buero')` β€” welche Geraete gibt es dort " + "(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das " + "gemeinte Geraet zu finden, BEVOR Du steuerst.\n" + " 3. `satellite_command(...)` β€” Aktion ausfuehren.\n" + "\n" + "Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n" + " satellite_command(satellite='Buero', device='Fire TV', " + "action='dial.launch', params={'app':'YouTube','v':''})\n" + "Die YouTube-Video-ID (v=) ziehst Du aus dem Link/Titel (ggf. web_search). " + "Weitere Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, " + "'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks.\n" + "\n" + "Adressierung ueber Location/Name des Satelliten ('Buero'), NICHT ueber die " + "Geraete β€” die identifizieren sich selbst. Steuerung geht nur, wenn der " + "Satellit sie erlaubt (steht in satellite_list als caps). Ist keiner online: " + "sag das ehrlich, statt zu raten." + ), + }, { "migration_key": "seed/architecture/brain-tools-xml-tag", "type": "rule", diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index dadb01e..51191ba 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -739,6 +739,11 @@ class ARIABridge: # Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON). self._vnc_sessions: dict[str, dict] = {} self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal") + # Satelliten (Aussenposten in fremden Netzen). id β†’ {location, caps, + # control, last_seen}. Registrierung via sat_hello. _pending_sat: + # requestId β†’ Future (sat_devices / sat_result), analog _pending_flux. + self._satellites: dict[str, dict] = {} + self._pending_sat: dict[str, asyncio.Future] = {} # FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten. # requestId β†’ Future mit dem flux_response-Payload (oder None bei Fehler). self._pending_flux: dict[str, asyncio.Future] = {} @@ -3411,6 +3416,32 @@ class ARIABridge: logger.warning("[vnc] input schreiben (%s) fehlgeschlagen: %s", session, exc) return + elif msg_type == "sat_hello": + sid = (payload.get("id") or "").strip() + if sid: + self._satellites[sid] = { + "id": sid, + "location": payload.get("location") or sid, + "caps": payload.get("caps") or [], + "control": bool(payload.get("control")), + "last_seen": time.time(), + } + logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s", + sid, self._satellites[sid]["location"], + self._satellites[sid]["caps"], self._satellites[sid]["control"]) + return + + elif msg_type in ("sat_devices", "sat_result"): + req_id = payload.get("requestId", "") + future = self._pending_sat.get(req_id) + if future is not None and not future.done(): + future.set_result(payload) + # last_seen aktualisieren + sid = (payload.get("satellite") or "").strip() + if sid and sid in self._satellites: + self._satellites[sid]["last_seen"] = time.time() + return + elif msg_type == "config_request": # Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice- # Config β€” passiert wenn sie sich connected, weil sie sonst die @@ -4282,6 +4313,27 @@ class ARIABridge: "timestamp": int(time.time() * 1000), })) await _send_response(writer, 200, {"ok": True}) + elif method == "POST" and path == "/internal/satellite-list": + # Brain fragt: welche Satelliten/Netze sind online + Capabilities. + await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()}) + elif method == "POST" and path == "/internal/satellite": + # Brain-Tool: Discovery oder Command an einen Satelliten. + # body: {op:'discover'|'command', satellite, device?, action?, params?} + try: + data = json.loads(body.decode("utf-8", "ignore")) + except Exception as exc: + await _send_response(writer, 400, {"error": f"bad json: {exc}"}) + return + op = (data.get("op") or "discover").strip() + result = await self._satellite_request( + op=op, + satellite=str(data.get("satellite") or ""), + device=str(data.get("device") or ""), + action=str(data.get("action") or ""), + params=data.get("params") if isinstance(data.get("params"), dict) else {}, + timeout=float(data.get("timeout") or 20.0), + ) + await _send_response(writer, 200, result) elif method == "POST" and path == "/internal/flux-generate": # Vom Brain (flux_generate-Tool) gefeuert. Wir routen den # Render-Request via RVS an die flux-bridge (Gamebox), @@ -4501,6 +4553,54 @@ class ARIABridge: pass logger.info("[vnc] Tunnel geschlossen: session=%s", session) + def _satellite_list(self) -> list[dict]: + """Bekannte Satelliten (frisch = in den letzten 5 Min gesehen).""" + now = time.time() + out = [] + for s in self._satellites.values(): + out.append({ + "id": s["id"], "location": s.get("location") or s["id"], + "caps": s.get("caps") or [], "control": bool(s.get("control")), + "online": (now - s.get("last_seen", 0)) < 300, + }) + return out + + async def _satellite_request(self, op: str, satellite: str = "", + device: str = "", action: str = "", + params: Optional[dict] = None, + timeout: float = 20.0) -> dict: + """Schickt sat_discover / sat_command an einen Satelliten (via RVS) und + wartet auf sat_devices / sat_result. op = 'discover' | 'command'. + Muster identisch zu _flux_generate (requestId β†’ Future).""" + if self.ws_rvs is None: + return {"ok": False, "error": "RVS-Verbindung nicht aktiv"} + request_id = str(uuid.uuid4()) + loop = asyncio.get_event_loop() + future: asyncio.Future = loop.create_future() + self._pending_sat[request_id] = future + try: + if op == "discover": + msg = {"type": "sat_discover", + "payload": {"requestId": request_id, "satellite": satellite, + "force": bool((params or {}).get("force"))}} + else: + msg = {"type": "sat_command", + "payload": {"requestId": request_id, "satellite": satellite, + "device": device, "action": action, + "params": params or {}}} + msg["timestamp"] = int(time.time() * 1000) + ok = await self._send_to_rvs(msg) + if not ok: + return {"ok": False, "error": "Satellit-Request konnte nicht gesendet werden"} + result = await asyncio.wait_for(future, timeout=timeout) + return result if isinstance(result, dict) else {"ok": False, "error": "ungueltige Antwort"} + except asyncio.TimeoutError: + return {"ok": False, "error": f"Satellit '{satellite or 'all'}' antwortet nicht (Timeout)."} + except Exception as exc: + return {"ok": False, "error": str(exc)} + finally: + self._pending_sat.pop(request_id, None) + async def _delete_chat_message(self, ts: int) -> dict: """Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation, broadcastet chat_message_deleted via RVS. diff --git a/rvs/server.js b/rvs/server.js index aa7fb40..be25f0e 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -72,6 +72,10 @@ const ALLOWED_TYPES = new Set([ "code_file", "code_file_edit", "check_desktop", "desktop_status", "vnc_open", "vnc_close", "vnc_data", "vnc_input", + // Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit + // sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und + // fuehren Aktionen aus (sat_command β†’ sat_result). + "sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result", ]); // Token-Raum: token -> { clients: Set } diff --git a/satellite/.env.example b/satellite/.env.example new file mode 100644 index 0000000..552ed96 --- /dev/null +++ b/satellite/.env.example @@ -0,0 +1,34 @@ +# ─── ARIA Satellit β€” Konfiguration ────────────────────────────────── +# Kopiere diese Datei nach .env und passe sie an. + +# RVS-Zugang (identisch zum Haupt-Stack β€” gleicher Raum/Token, damit ARIA +# diesen Satelliten erreicht). Werte aus der Haupt-.env / vom generate-token.sh. +RVS_HOST=rvs.example.de +RVS_PORT=443 +RVS_TLS=true +RVS_TOKEN= + +# ─── Identitaet / Adresse dieses Satelliten ──────────────────────── +# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug. +# SATELLITE_LOCATION = menschlicher Name, so spricht ARIA das Netz an ("Buero"). +# Mehrere Satelliten koennen im selben RVS-Raum haengen β€” die Location +# unterscheidet sie ("Buero", "Zuhause", "Werkstatt"). +SATELLITE_ID=buero +SATELLITE_LOCATION=BΓΌro + +# ─── Steuerung (Sicherheit!) ─────────────────────────────────────── +# CONTROL_ENABLED=false β†’ reiner Info-/Beobachtungs-Satellit (entdeckt & meldet +# nur, steuert nichts). Sicherste Basis. +# CONTROL_ENABLED=true β†’ darf Geraete steuern (nur Aktionen aus der Allowlist). +CONTROL_ENABLED=true +# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt. +# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV) +# wol Wake-on-LAN (Geraet per MAC aufwecken) +# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks) +# http.post generischer HTTP-POST +CONTROL_ALLOWLIST=dial.launch,wol,http.get + +# ─── Discovery-Tuning (optional) ─────────────────────────────────── +SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall +DISCOVER_TIMEOUT_SEC=6 # Dauer eines Sweeps (mDNS + SSDP) +DEVICE_CACHE_TTL_SEC=120 # wie lange ein Inventar als "frisch" gilt diff --git a/satellite/Dockerfile b/satellite/Dockerfile new file mode 100644 index 0000000..9436edc --- /dev/null +++ b/satellite/Dockerfile @@ -0,0 +1,13 @@ +# ARIA Satellit β€” schlanker Aussenposten-Container. +# Laeuft mit network_mode: host (siehe docker-compose.yml), damit mDNS/SSDP- +# Broadcasts + die Geraete-IPs im lokalen Netz erreichbar sind. +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY satellite.py . + +CMD ["python", "-u", "satellite.py"] diff --git a/satellite/README.md b/satellite/README.md new file mode 100644 index 0000000..026310c --- /dev/null +++ b/satellite/README.md @@ -0,0 +1,53 @@ +# ARIA Satellit πŸ›°οΈ + +Ein eigenstΓ€ndiger **Außenposten-Container** fΓΌr ein fremdes Netz (BΓΌro, Werkstatt, +Ferienwohnung …). Er verbindet sich als RVS-Client in Stefans Raum und gibt ARIA +**Augen und HΓ€nde in genau diesem Netz** β€” ohne dass der Haupt-Stack dort stehen muss. + +- **Augen (Info):** entdeckt GerΓ€te via **mDNS/Zeroconf** (Chromecast, AirPlay, Sonos, + Drucker, NAS …), **SSDP/UPnP + DIAL** (Smart-TVs, Fire TV) und der **ARP-Tabelle** + (rohe Hosts). Meldet ARIA ein Live-Inventar. +- **HΓ€nde (Steuerung):** **DIAL-App-Launch** (z.B. YouTube-Video auf dem Fire TV), + **Wake-on-LAN**, generisches **HTTP**. Nur wenn freigeschaltet (siehe Sicherheit). + +## Deploy + +```bash +cd satellite +cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen +docker compose up -d --build +docker compose logs -f # "sat_hello gesendet" + "[scan] N Geraete" +``` + +`RVS_HOST/PORT/TLS/TOKEN` **identisch** zum Haupt-Stack (gleicher Raum, damit ARIA +den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, ΓΌber den ARIA das Netz +anspricht (β€žBΓΌro"). + +> **`network_mode: host` ist Pflicht** (schon in der compose gesetzt): nur so sieht +> der Container die mDNS/SSDP-Broadcasts und die GerΓ€te-IPs des LAN. + +## So nutzt ARIA es + +ARIA hat drei Brain-Tools: +- `satellite_list` β€” welche Netze/Satelliten sind online + was kΓΆnnen sie. +- `satellite_devices(satellite)` β€” Inventar eines Netzes. +- `satellite_command(satellite, device, action, params)` β€” Aktion ausfΓΌhren. + +Beispiel β€žYouTube-Video auf dem BΓΌro-Stick": +``` +satellite_command(satellite="BΓΌro", device="Fire TV", + action="dial.launch", params={"app":"YouTube","v":""}) +``` + +## Sicherheit + +Der Satellit scannt ein Netz **und** ist ΓΌber einen Cloud-Relay erreichbar β€” deshalb: + +- Reagiert **nur** auf den eigenen RVS-Raum (Token). +- **`CONTROL_ENABLED=false`** = reiner Info-Satellit (steuert nichts). Standard-sicher. +- Bei `true`: nur Aktionen aus **`CONTROL_ALLOWLIST`**, alles andere wird abgelehnt. +- Jede ausgefΓΌhrte Aktion wird **geloggt**. +- Keine offenen Ports β€” reiner Client. + +Empfehlung: in vertrauenswΓΌrdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist; +sonst `false` und nur beobachten. diff --git a/satellite/docker-compose.yml b/satellite/docker-compose.yml new file mode 100644 index 0000000..aaea78c --- /dev/null +++ b/satellite/docker-compose.yml @@ -0,0 +1,18 @@ +# ARIA Satellit β€” eigenstaendiger Stack fuer ein fremdes Netz (Buero, Werkstatt …). +# +# Deploy: +# cd satellite +# cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen +# docker compose up -d --build +# +# WICHTIG: network_mode: host β€” der Satellit MUSS im Host-Netz laufen, sonst +# sieht er die mDNS/SSDP-Broadcasts + Geraete-IPs des LAN nicht (Docker-Bridge +# wuerde das isolieren). Damit ist er zugleich als RVS-Client raus ins Internet +# verbunden. Keine Ports zu veroeffentlichen β€” er ist reiner Client. +services: + satellite: + build: . + container_name: aria-satellite + network_mode: host + env_file: .env + restart: unless-stopped diff --git a/satellite/requirements.txt b/satellite/requirements.txt new file mode 100644 index 0000000..79aa1e6 --- /dev/null +++ b/satellite/requirements.txt @@ -0,0 +1,3 @@ +websockets>=12.0 +zeroconf>=0.131.0 +requests>=2.31.0 diff --git a/satellite/satellite.py b/satellite/satellite.py new file mode 100644 index 0000000..690dc0e --- /dev/null +++ b/satellite/satellite.py @@ -0,0 +1,576 @@ +""" +ARIA Satellit β€” Info-/Gateway-Aussenposten in einem fremden Netz. + +Laeuft eigenstaendig (z.B. im Buero) und verbindet sich als RVS-Client in +Stefans Raum (gleicher Token). Gibt ARIA damit Augen + Haende in DIESEM Netz: + + Augen: entdeckt Geraete (mDNS/Zeroconf, SSDP/UPnP + DIAL, ARP-Tabelle) und + meldet ein Inventar β†’ sat_devices. + Haende: steuert Geraete (DIAL-App-Launch z.B. YouTube auf Fire TV, Wake-on- + LAN, generisches HTTP) β†’ sat_command / sat_result. Nur wenn + CONTROL_ENABLED=true, Aktion in der Allowlist, alles geloggt. + +Adressierung: mehrere Satelliten haengen im selben RVS-Raum. Jeder hat eine +SATELLITE_ID (technisch, eindeutig) + SATELLITE_LOCATION (menschlich, "Buero"). +ARIA spricht einen Satelliten ueber seine ID/Location an. + +Message-Typen (RVS, Base64/JSON-Relay wie der Rest): + raus: sat_hello {id, location, caps, ts} + sat_devices {requestId, satellite, devices:[...]} + sat_result {requestId, satellite, ok, result|error} + rein: sat_discover {satellite?, requestId} + sat_command {satellite?, requestId, device, action, params} + +Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token). Commands brauchen +CONTROL_ENABLED + Allowlist. Discovery ist read-only. Keine offenen Ports. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import socket +import struct +import time +from typing import Optional + +import websockets + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [satellite] %(levelname)s %(message)s", +) +logger = logging.getLogger("satellite") + + +# ─── Konfiguration ────────────────────────────────────────────────── + +def _env_bool(name: str, default: bool) -> bool: + v = os.environ.get(name) + if v is None: + return default + return v.strip().lower() in ("1", "true", "yes", "on", "ja") + + +def _default_id() -> str: + host = socket.gethostname() or "satellite" + slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower() + return slug or "satellite" + + +RVS_HOST = os.environ.get("RVS_HOST", "") +RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443") +RVS_TLS = _env_bool("RVS_TLS", True) +RVS_TOKEN = os.environ.get("RVS_TOKEN", "") + +SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip() +SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip() + +CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False) +CONTROL_ALLOWLIST = [ + a.strip() for a in + os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get").split(",") + if a.strip() +] + +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") + +HEARTBEAT_SEC = 25 + +# mDNS-Servicetypen, die fuer ARIA interessant sind. +MDNS_TYPES = [ + "_googlecast._tcp.local.", # Chromecast / Google TV / Nest + "_airplay._tcp.local.", # Apple TV / AirPlay + "_raop._tcp.local.", # AirPlay-Audio + "_spotify-connect._tcp.local.", # Spotify-Geraete + "_sonos._tcp.local.", # Sonos + "_hap._tcp.local.", # HomeKit + "_printer._tcp.local.", # Drucker + "_ipp._tcp.local.", # Drucker (IPP) + "_smb._tcp.local.", # NAS / Fileshares + "_workstation._tcp.local.", # generische Hosts + "_http._tcp.local.", # Web-UIs (Router, NAS, IoT) +] + +CAPABILITIES = ["discover"] +if CONTROL_ENABLED: + CAPABILITIES += CONTROL_ALLOWLIST + + +# ─── Discovery ────────────────────────────────────────────────────── + +def _discover_mdns(timeout: float) -> list[dict]: + """Blockierend (im Executor): mDNS/Zeroconf-Sweep ueber MDNS_TYPES.""" + out: dict[str, dict] = {} + try: + from zeroconf import Zeroconf, ServiceBrowser + except Exception as exc: + logger.warning("zeroconf nicht verfuegbar: %s", exc) + return [] + + class _Listener: + def add_service(self, zc, type_, name): + try: + info = zc.get_service_info(type_, name, timeout=2000) + except Exception: + info = None + if not info: + return + ips = [] + try: + for addr in info.parsed_addresses(): + ips.append(addr) + except Exception: + pass + props = {} + try: + for k, v in (info.properties or {}).items(): + try: + props[k.decode("utf-8", "ignore")] = ( + v.decode("utf-8", "ignore") if isinstance(v, (bytes, bytearray)) else v) + except Exception: + pass + except Exception: + pass + friendly = name.split("." + type_.split(".", 1)[0])[0].strip(".") + fn = props.get("fn") or props.get("friendlyName") or friendly + dev_id = _slug(f"{fn}-{ips[0] if ips else name}") + out[dev_id] = { + "id": dev_id, + "name": fn, + "type": _mdns_kind(type_), + "ip": ips[0] if ips else "", + "port": info.port, + "via": "mdns", + "service": type_, + "model": props.get("md") or props.get("model") or "", + } + + def update_service(self, *a): + pass + + def remove_service(self, *a): + pass + + zc = None + try: + zc = Zeroconf() + listener = _Listener() + for t in MDNS_TYPES: + try: + ServiceBrowser(zc, t, listener) + except Exception: + pass + time.sleep(timeout) + except Exception as exc: + logger.warning("mDNS-Sweep-Fehler: %s", exc) + finally: + try: + if zc: + zc.close() + except Exception: + pass + return list(out.values()) + + +def _mdns_kind(service_type: str) -> str: + m = { + "_googlecast": "cast", "_airplay": "airplay", "_raop": "airplay-audio", + "_spotify-connect": "spotify", "_sonos": "sonos", "_hap": "homekit", + "_printer": "printer", "_ipp": "printer", "_smb": "fileshare", + "_workstation": "host", "_http": "web", + } + for k, v in m.items(): + if service_type.startswith(k): + return v + return "unknown" + + +def _discover_ssdp(timeout: float) -> list[dict]: + """Blockierend: SSDP M-SEARCH (UPnP + DIAL). Liefert v.a. Smart-TVs / Fire + TV mit ihrer DIAL Application-URL (fuer App-Launch wie YouTube).""" + out: dict[str, dict] = {} + targets = [ + "urn:dial-multiscreen-org:service:dial:1", + "ssdp:all", + ] + for st in targets: + msg = ( + "M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + 'MAN: "ssdp:discover"\r\n' + "MX: 2\r\n" + f"ST: {st}\r\n\r\n" + ).encode("utf-8") + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + sock.settimeout(timeout) + sock.sendto(msg, ("239.255.255.250", 1900)) + deadline = time.time() + timeout + while time.time() < deadline: + try: + data, addr = sock.recvfrom(65507) + except socket.timeout: + break + except Exception: + break + headers = _parse_http_headers(data.decode("utf-8", "ignore")) + location = headers.get("location", "") + dial_app = headers.get("application-url", "") + ip = addr[0] + dev = _fetch_upnp_description(location) if location else {} + name = dev.get("name") or headers.get("server", "") or ip + dev_id = _slug(f"{name}-{ip}") + entry = out.get(dev_id, { + "id": dev_id, "name": name, "type": "media-renderer", + "ip": ip, "via": "ssdp", + }) + if dev.get("name"): + entry["name"] = dev["name"] + if dev.get("model"): + entry["model"] = dev["model"] + if dev.get("manufacturer"): + entry["manufacturer"] = dev["manufacturer"] + if dial_app or dev.get("dialAppUrl"): + entry["dialAppUrl"] = dial_app or dev.get("dialAppUrl") + entry["type"] = "dial" + out[dev_id] = entry + except Exception as exc: + logger.debug("SSDP (%s) Fehler: %s", st, exc) + finally: + try: + if sock: + sock.close() + except Exception: + pass + return list(out.values()) + + +def _fetch_upnp_description(location: str) -> dict: + try: + import requests + r = requests.get(location, timeout=3) + dial_app = r.headers.get("Application-URL", "") + xml = r.text + name = _xml_tag(xml, "friendlyName") + model = _xml_tag(xml, "modelName") + manuf = _xml_tag(xml, "manufacturer") + return {"name": name, "model": model, "manufacturer": manuf, "dialAppUrl": dial_app} + except Exception: + return {} + + +def _discover_arp() -> list[dict]: + """Rohe Host-Liste aus der ARP-Tabelle (kein aktiver Scan).""" + out = [] + try: + with open("/proc/net/arp", "r", encoding="utf-8") as f: + lines = f.read().splitlines()[1:] + for ln in lines: + parts = ln.split() + if len(parts) < 4: + continue + ip, _hw, _flags, mac = parts[0], parts[1], parts[2], parts[3] + if mac == "00:00:00:00:00:00": + continue + out.append({ + "id": _slug(f"host-{ip}"), "name": ip, "type": "host", + "ip": ip, "mac": mac, "via": "arp", + }) + except Exception: + pass + return out + + +def _merge_devices(*lists) -> list[dict]: + """Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt).""" + by_ip: dict[str, dict] = {} + loose: list[dict] = [] + order = {"mdns": 3, "ssdp": 2, "arp": 1} + for lst in lists: + for d in lst: + ip = d.get("ip") or "" + if not ip: + loose.append(d) + continue + cur = by_ip.get(ip) + if not cur: + by_ip[ip] = d + else: + # bessere Quelle / mehr Felder β†’ mergen + merged = {**d, **{k: v for k, v in cur.items() if v}} + if order.get(d.get("via"), 0) >= order.get(cur.get("via"), 0): + merged.update({k: v for k, v in d.items() if v}) + # DIAL-URL / mac aus beiden behalten + for key in ("dialAppUrl", "mac", "model", "manufacturer"): + merged[key] = d.get(key) or cur.get(key) or merged.get(key) + by_ip[ip] = {k: v for k, v in merged.items() if v not in (None, "")} + return list(by_ip.values()) + loose + + +# ─── Control ──────────────────────────────────────────────────────── + +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: + 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() + try: + if action == "dial.launch": + return await loop.run_in_executor(None, _do_dial_launch, params, devices) + if action == "wol": + return await loop.run_in_executor(None, _do_wol, params) + if action in ("http.get", "http.post"): + return await loop.run_in_executor(None, _do_http, action, params) + return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."} + except Exception as exc: + return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"} + + +def _find_device(devices: list[dict], ref: str) -> Optional[dict]: + ref = (ref or "").strip().lower() + if not ref: + return None + for d in devices: + if d.get("id", "").lower() == ref or d.get("ip", "") == ref: + return d + for d in devices: + if ref in (d.get("name", "").lower()): + return d + return None + + +def _do_dial_launch(params: dict, devices: list[dict]) -> dict: + """DIAL-App-Launch, z.B. YouTube-Video auf Fire TV / Smart-TV. + params: {device, app='YouTube', v= (oder beliebige app-params)}""" + import requests + ref = params.get("device") or "" + dev = _find_device(devices, ref) + app_url = (dev or {}).get("dialAppUrl") if dev else params.get("dialAppUrl") + if not app_url: + return {"ok": False, "error": f"Kein DIAL-Geraet fuer '{ref}' gefunden (oder keine Application-URL)."} + app = params.get("app") or "YouTube" + # app-Parameter (alles ausser device/app) als form-urlencoded Body. + body = {k: v for k, v in (params or {}).items() if k not in ("device", "app", "dialAppUrl")} + url = app_url.rstrip("/") + "/" + app + r = requests.post(url, data=body, timeout=5) + ok = r.status_code in (200, 201) + return {"ok": ok, "result": f"DIAL {app} β†’ {(dev or {}).get('name', ref)} (HTTP {r.status_code})" + if ok else None, + "error": None if ok else f"DIAL-Launch HTTP {r.status_code}: {r.text[:120]}"} + + +def _do_wol(params: dict) -> dict: + mac = (params.get("mac") or "").strip() + if not re.match(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$", mac): + return {"ok": False, "error": f"Ungueltige MAC: {mac!r}"} + clean = re.sub(r"[:-]", "", mac) + packet = b"\xff" * 6 + bytes.fromhex(clean) * 16 + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + s.sendto(packet, ("255.255.255.255", 9)) + s.close() + return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."} + + +def _do_http(action: str, params: dict) -> dict: + import requests + url = params.get("url") or "" + if not url.startswith(("http://", "https://")): + return {"ok": False, "error": "url (http/https) erforderlich."} + method = "GET" if action == "http.get" else "POST" + r = requests.request(method, url, data=params.get("body"), + headers=params.get("headers"), timeout=6) + return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}} + + +# ─── Helpers ──────────────────────────────────────────────────────── + +def _slug(s: str) -> str: + s = (s or "").strip().lower() + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") + return s or "dev" + + +def _parse_http_headers(text: str) -> dict: + headers = {} + for line in text.split("\r\n")[1:]: + if ":" in line: + k, _, v = line.partition(":") + headers[k.strip().lower()] = v.strip() + return headers + + +def _xml_tag(xml: str, tag: str) -> str: + m = re.search(rf"<{tag}>(.*?)", xml, re.IGNORECASE | re.DOTALL) + return m.group(1).strip() if m else "" + + +# ─── Satellit (RVS-Client) ────────────────────────────────────────── + +class Satellite: + def __init__(self) -> None: + self.ws: Optional[websockets.WebSocketClientProtocol] = None + self._devices: list[dict] = [] + self._devices_ts: float = 0.0 + self._scanning = False + + async def _scan(self, force: bool = False) -> list[dict]: + fresh = (time.time() - self._devices_ts) < DEVICE_CACHE_TTL_SEC + if self._devices and fresh and not force: + return self._devices + if self._scanning: + # Laufenden Scan abwarten (grob) + for _ in range(30): + await asyncio.sleep(0.2) + if not self._scanning: + break + return self._devices + self._scanning = True + try: + loop = asyncio.get_event_loop() + mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC) + ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC) + arp = await loop.run_in_executor(None, _discover_arp) + self._devices = _merge_devices(mdns, ssdp, arp) + self._devices_ts = time.time() + logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)", + len(self._devices), len(mdns), len(ssdp), len(arp)) + finally: + self._scanning = False + return self._devices + + async def _send(self, message: dict) -> None: + if self.ws is None: + return + try: + await self.ws.send(json.dumps(message)) + except Exception as exc: + logger.warning("send fehlgeschlagen: %s", exc) + + async def _hello(self, log: bool = False) -> None: + await self._send({ + "type": "sat_hello", + "payload": { + "id": SATELLITE_ID, + "location": SATELLITE_LOCATION, + "caps": CAPABILITIES, + "control": CONTROL_ENABLED, + }, + "timestamp": int(time.time() * 1000), + }) + if log: + logger.info("sat_hello gesendet: id=%s location=%s caps=%s", + SATELLITE_ID, SATELLITE_LOCATION, CAPABILITIES) + + def _for_me(self, payload: dict) -> bool: + target = (payload.get("satellite") or "").strip().lower() + if not target or target in ("all", "*"): + return True + return target in (SATELLITE_ID.lower(), SATELLITE_LOCATION.lower()) + + async def _handle(self, raw: str) -> None: + try: + msg = json.loads(raw) + except Exception: + return + mtype = msg.get("type", "") + payload = msg.get("payload", {}) or {} + + if mtype == "sat_discover": + if not self._for_me(payload): + return + req_id = payload.get("requestId", "") + devices = await self._scan(force=bool(payload.get("force"))) + await self._send({ + "type": "sat_devices", + "payload": {"requestId": req_id, "satellite": SATELLITE_ID, + "location": SATELLITE_LOCATION, "devices": devices}, + "timestamp": int(time.time() * 1000), + }) + + elif mtype == "sat_command": + if not self._for_me(payload): + return + req_id = payload.get("requestId", "") + action = (payload.get("action") or "").strip() + params = payload.get("params") or {} + if payload.get("device") and "device" not in params: + params["device"] = payload.get("device") + devices = await self._scan() + result = await _control(action, params, devices) + await self._send({ + "type": "sat_result", + "payload": {"requestId": req_id, "satellite": SATELLITE_ID, **result}, + "timestamp": int(time.time() * 1000), + }) + + async def _periodic_scan(self) -> None: + while True: + try: + await self._scan(force=True) + except Exception as exc: + logger.warning("periodischer Scan-Fehler: %s", exc) + await asyncio.sleep(SCAN_INTERVAL_SEC) + + async def _heartbeat(self) -> None: + # Re-announce bei jedem Heartbeat: falls die Bridge NACH uns (neu) + # verbindet, lernt sie uns so innerhalb von HEARTBEAT_SEC β€” RVS replayt + # nichts. Haelt zugleich last_seen in der Bridge-Registry frisch. + while True: + await asyncio.sleep(HEARTBEAT_SEC) + await self._send({"type": "heartbeat", "timestamp": int(time.time() * 1000)}) + await self._hello() + + async def run(self) -> None: + if not RVS_HOST or not RVS_TOKEN: + logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).") + return + asyncio.create_task(self._periodic_scan()) + backoff = 1 + while True: + proto = "wss" if RVS_TLS else "ws" + url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}" + try: + logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT) + async with websockets.connect(url, max_size=8 * 1024 * 1024, + ping_interval=20, ping_timeout=20) as ws: + self.ws = ws + backoff = 1 + await self._hello(log=True) + hb = asyncio.create_task(self._heartbeat()) + try: + async for raw in ws: + await self._handle(raw) + finally: + hb.cancel() + except Exception as exc: + logger.warning("RVS-Verbindung verloren: %s", exc) + finally: + self.ws = None + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 30) + + +def main() -> None: + logger.info("ARIA Satellit startet β€” id=%s location=%s control=%s", + SATELLITE_ID, SATELLITE_LOCATION, CONTROL_ENABLED) + try: + asyncio.run(Satellite().run()) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main()