From cfd17b200a6f5eefb2875ae53a40555e61b5bbe4 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 4 Oct 2026 18:35:40 +0200 Subject: [PATCH] feat(satellite): Port-Tunnel ins fremde Netz + Geraete-Namen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARIA sitzt woanders und kommt nicht ins Buero-Netz. Der Satellit kann beides: er sieht das LAN und haelt eine ausgehende Verbindung zum RVS. Also reicht er eine TCP-Verbindung durch — wie der VNC-Tunnel, nur fuer beliebige Ports. Damit erreicht ARIA einen FreeCAD-MCP, eine Web-App, ein NAS-Interface. Entscheidend fuer die Brauchbarkeit: die Bridge macht daraus einen GANZ NORMALEN lokalen Port (aria-bridge:19000...). Dadurch funktioniert alles, was sowieso TCP spricht — MCP-Client, curl, Browser — ohne Sonder-API. Jede eingehende Verbindung bekommt einen eigenen Tunnel, parallele Verbindungen sind also kein Problem (ein Browser oeffnet gern sechs). Geraete-Namen: "duffy-desktop" ist ansprechbar, "192.168.1.47" nicht. Stefan kann jedem gefundenen Geraet einen Namen geben, ARIA adressiert danach. Schluessel ist die MAC, nicht die IP — per DHCP wandert die IP, die MAC bleibt. Absicherung, weil das ein Weg in ein fremdes Netz ist: - TUNNEL_ENABLED default AUS, pro Satellit. - Ohne TUNNEL_ALLOW nur Ziele, die bei der Suche GEFUNDEN wurden. Der Tunnel kann damit nichts ansteuern, was nicht ohnehin im LAN sichtbar ist — und nichts im Internet. - Obergrenze gleichzeitiger Tunnel + Reaper fuer ungenutzte (sonst bleibt eine vergessene Verbindung offen, bis der Satellit neu startet). - Faehigkeit 'tunnel' wird im sat_hello gemeldet, damit Diagnostic und ARIA sehen, ob der Satellit das ueberhaupt erlaubt. Neue RVS-Typen eingetragen (sat_tunnel_*, sat_name_*) — ohne das verwirft die Allow-List sie still, und nichts funktioniert ohne Fehlermeldung. 16 Pruefungen gegen BEIDE echten Implementierungen (nur der RVS dazwischen ist kurzgeschlossen): Namensaufloesung, Freigabe-Regeln, fremder Satellit ignoriert, abgeschaltet blockt, echter Byte-Durchlauf (PONG:HALLO), unbekanntes Ziel schliesst sauber statt zu haengen, Liste/Schliessen/Doppel-Schliessen. Co-Authored-By: Claude Opus 5 (1M context) --- aria-brain/agent.py | 122 ++++++++++++++++++ bridge/aria_bridge.py | 187 +++++++++++++++++++++++++++ rvs/server.js | 8 ++ satellite/.env.example | 22 ++++ satellite/satellite.py | 285 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 622 insertions(+), 2 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index b5102bf..ffdbaab 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1302,6 +1302,66 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "satellite_tunnel", + "description": ( + "Macht einen Port aus dem Netz eines Satelliten HIER erreichbar. " + "Damit kommst Du an Dienste, die nur im fremden LAN lauschen — " + "z.B. ein FreeCAD-MCP auf Stefans Rechner, eine Web-App, ein " + "NAS-Interface.\n\n" + "op='open': Du bekommst eine Adresse wie `aria-bridge:19000` " + "zurueck. Die sprichst Du danach ganz normal an (http://..., " + "MCP, was auch immer) — als laege der Dienst nebenan.\n" + "op='list': welche Tunnel stehen gerade.\n" + "op='close': wieder zumachen (localPort angeben). Mach zu, was " + "Du nicht mehr brauchst — ein Tunnel ist ein offener Weg in ein " + "fremdes Netz.\n\n" + "target darf ein VERGEBENER GERAETENAME sein ('duffy-desktop') " + "oder eine IP. Namen stehen in satellite_devices im Feld 'name' " + "— das ist der bequeme Weg, Stefan spricht so ueber seine " + "Rechner.\n\n" + "Der Satellit muss das erlauben (TUNNEL_ENABLED, Faehigkeit " + "'tunnel' in satellite_list). Ohne Freigabe kommt eine klare " + "Fehlermeldung — dann Stefan Bescheid sagen, nicht herumprobieren." + ), + "parameters": { + "type": "object", + "properties": { + "op": {"type": "string", "description": "open (Default) | list | close"}, + "satellite": {"type": "string", "description": "Satelliten-Name/ID."}, + "target": {"type": "string", "description": "Geraetename aus satellite_devices ODER IP."}, + "port": {"type": "integer", "description": "Port im fremden Netz, z.B. 8080."}, + "localPort": {"type": "integer", "description": "Optional fester lokaler Port (19000-19099); bei close Pflicht."}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "satellite_name", + "description": ( + "Gibt einem Geraet im Satelliten-Netz einen Namen, damit man es " + "danach ansprechen kann ('duffy-desktop' statt '192.168.1.47'). " + "Der Name haengt an der MAC, ueberlebt also einen IP-Wechsel. " + "Leerer Name = Zuordnung entfernen. " + "Nimm mac aus satellite_devices, wenn vorhanden — sonst ip." + ), + "parameters": { + "type": "object", + "properties": { + "satellite": {"type": "string", "description": "Satelliten-Name/ID."}, + "mac": {"type": "string", "description": "MAC des Geraets (bevorzugt)."}, + "ip": {"type": "string", "description": "IP, falls keine MAC bekannt."}, + "name": {"type": "string", "description": "Der Name. Leer = entfernen."}, + }, + "required": ["satellite", "name"], + }, + }, + }, { "type": "function", "function": { @@ -3111,6 +3171,64 @@ class Agent: except Exception as exc: return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {exc}" + def _dispatch_tunnel(self, arguments: dict) -> str: + """satellite_tunnel — Port aus einem Satelliten-LAN hier verfuegbar machen.""" + op = (arguments.get("op") or "open").strip().lower() + body = {"op": op, + "satellite": (arguments.get("satellite") or "").strip(), + "target": (arguments.get("target") or "").strip(), + "port": int(arguments.get("port") or 0), + "localPort": int(arguments.get("localPort") or 0)} + try: + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(f"{BRIDGE_URL}/internal/tunnel", data=data, + method="POST", + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as resp: + res = json.loads(resp.read().decode("utf-8", "ignore")) + except Exception as exc: + return f"FEHLER: Bridge nicht erreichbar: {exc}" + + if op == "list": + tunnels = res.get("tunnels") or [] + if not tunnels: + return "Es steht gerade kein Tunnel offen." + return "Offene Tunnel:\n" + "\n".join( + f"- {t['address']} -> {t['satellite']}/{t['target']}:{t['port']}" + for t in tunnels) + if not res.get("ok"): + return f"FEHLER: {res.get('error')}" + if op == "close": + return f"OK — Tunnel auf Port {res.get('localPort')} geschlossen." + return (f"OK — {body['target']}:{body['port']} aus dem Netz von " + f"'{body['satellite']}' ist jetzt erreichbar unter " + f"**{res.get('address')}**.\n" + f"Sprich diese Adresse ganz normal an (http://{res.get('address')}/..., " + f"MCP, ...). Wenn Du fertig bist: " + f"satellite_tunnel(op='close', localPort={res.get('localPort')}).") + + def _dispatch_sat_name(self, arguments: dict) -> str: + """satellite_name — Geraetenamen vergeben/entfernen.""" + mac = (arguments.get("mac") or "").strip() + ip = (arguments.get("ip") or "").strip() + if not mac and not ip: + return "FEHLER: mac oder ip ist Pflicht (siehe satellite_devices)." + nm = (arguments.get("name") or "").strip() + body = {"satellite": (arguments.get("satellite") or "").strip(), + "mac": mac, "ip": ip, "name": nm} + try: + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(f"{BRIDGE_URL}/internal/satellite-name", + data=data, method="POST", + headers={"Content-Type": "application/json"}) + urllib.request.urlopen(req, timeout=15).read() + except Exception as exc: + return f"FEHLER: Bridge nicht erreichbar: {exc}" + who = mac or ip + return (f"OK — {who} heisst jetzt '{nm}'. Du kannst es ab sofort so " + f"ansprechen (z.B. satellite_tunnel target='{nm}')." + if nm else f"OK — Name von {who} entfernt.") + def _dispatch_host(self, name: str, arguments: dict) -> str: """Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht @@ -4175,6 +4293,10 @@ class Agent: 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) + if name == "satellite_tunnel": + return self._dispatch_tunnel(arguments) + if name == "satellite_name": + return self._dispatch_sat_name(arguments) if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play", "jellyfin_control"): return self._dispatch_jellyfin(name, arguments) diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index b32c9a1..f324811 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -765,6 +765,17 @@ class ARIABridge: # "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem # Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON). self._vnc_sessions: dict[str, dict] = {} + # ── Satelliten-Port-Tunnel ─────────────────────────────────── + # ARIA sitzt hier, der Dienst steht in einem fremden LAN. Der Satellit + # haelt dorthin die Verbindung; wir machen daraus einen GANZ NORMALEN + # lokalen Port. Damit kann alles im Stack einfach draufzugreifen + # (MCP-Client, curl, Browser) statt eine Sonder-API zu brauchen. + # _tunnel_listeners: port -> {"server", "satellite", "target", "port"} + # _tunnel_conns: tunnelId -> {"writer", "opened" (Future)} + self._tunnel_listeners: dict[int, dict] = {} + self._tunnel_conns: dict[str, dict] = {} + self._TUNNEL_PORT_BASE = int(os.environ.get("TUNNEL_PORT_BASE", "19000")) + self._TUNNEL_PORT_MAX = int(os.environ.get("TUNNEL_PORT_MAX", "19099")) # VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC). # host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused. self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST") @@ -3599,6 +3610,42 @@ class ARIABridge: asyncio.create_task(self._check_desktop(session, port)) return + elif msg_type == "sat_tunnel_opened": + # Antwort des Satelliten auf unseren Verbindungswunsch. + tid = (payload.get("tunnelId") or "").strip() + conn = self._tunnel_conns.get(tid) + fut = conn.get("opened") if conn else None + if fut is not None and not fut.done(): + fut.set_result(payload) + return + + elif msg_type == "sat_tunnel_data": + # LAN -> lokaler Socket. Der Satellit schickt dasselbe Feld in die + # andere Richtung; unterschieden wird ueber die tunnelId, die nur + # hier bekannt ist. + tid = (payload.get("tunnelId") or "").strip() + conn = self._tunnel_conns.get(tid) + if conn: + try: + conn["writer"].write(base64.b64decode(payload.get("b64") or "")) + except Exception as exc: + logger.warning("[tunnel] schreiben (%s) fehlgeschlagen: %s", tid[:8], exc) + return + + elif msg_type == "sat_tunnel_closed": + tid = (payload.get("tunnelId") or "").strip() + conn = self._tunnel_conns.pop(tid, None) + if conn: + fut = conn.get("opened") + if fut is not None and not fut.done(): + fut.set_result({"ok": False, + "error": payload.get("reason") or "geschlossen"}) + try: + conn["writer"].close() + except Exception: + pass + return + elif msg_type == "vnc_open": session = str(payload.get("session") or "main") try: @@ -4700,6 +4747,45 @@ class ARIABridge: elif method in ("GET", "POST") and path == "/internal/worker-list": # Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte). await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()}) + elif method == "POST" and path == "/internal/tunnel": + # Brain-Tool: Port aus einem Satelliten-LAN lokal verfuegbar + # machen. body: {op: open|close|list, satellite, target, + # port, localPort?} + 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 "open").strip() + if op == "list": + await _send_response(writer, 200, {"ok": True, "tunnels": self.tunnel_list()}) + elif op == "close": + res = await self.tunnel_stop(int(data.get("localPort") or 0)) + await _send_response(writer, 200, res) + else: + res = await self.tunnel_listen( + (data.get("satellite") or "").strip(), + (data.get("target") or "").strip(), + int(data.get("port") or 0), + int(data.get("localPort") or 0)) + await _send_response(writer, 200, res) + elif method == "POST" and path == "/internal/satellite-name": + # Brain/Diagnostic: Geraetenamen vergeben. body: {satellite, mac|ip, name} + try: + data = json.loads(body.decode("utf-8", "ignore")) + except Exception as exc: + await _send_response(writer, 400, {"error": f"bad json: {exc}"}) + return + await self._send_to_rvs({ + "type": "sat_name_set", + "payload": {"satellite": data.get("satellite") or "", + "mac": data.get("mac") or "", + "ip": data.get("ip") or "", + "name": data.get("name") or "", + "requestId": uuid.uuid4().hex}, + "timestamp": int(time.time() * 1000), + }) + await _send_response(writer, 200, {"ok": True}) elif method == "POST" and path == "/internal/host-list": # Brain fragt: welche Host-Agenten (Rechner) sind online + Capabilities. await _send_response(writer, 200, {"ok": True, "hosts": self._host_list()}) @@ -4903,6 +4989,107 @@ class ARIABridge: "timestamp": int(time.time() * 1000), }) + # ── Satelliten-Port-Tunnel ────────────────────────────────────── + + async def tunnel_listen(self, satellite: str, target: str, port: int, + local_port: int = 0) -> dict: + """Macht aus /: einen lokalen Port. + + Jede eingehende Verbindung auf dem lokalen Port wird einzeln durch den + Satelliten zum Ziel gefuehrt — mehrere parallele Verbindungen sind also + kein Problem (ein Browser oeffnet gern sechs). + """ + if local_port and not (self._TUNNEL_PORT_BASE <= local_port <= self._TUNNEL_PORT_MAX): + return {"ok": False, "error": + f"localPort muss zwischen {self._TUNNEL_PORT_BASE} und " + f"{self._TUNNEL_PORT_MAX} liegen."} + if not local_port: + for p in range(self._TUNNEL_PORT_BASE, self._TUNNEL_PORT_MAX + 1): + if p not in self._tunnel_listeners: + local_port = p + break + if not local_port: + return {"ok": False, "error": "Keine freien Tunnel-Ports mehr."} + if local_port in self._tunnel_listeners: + await self.tunnel_stop(local_port) + + async def on_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + tid = uuid.uuid4().hex + opened: asyncio.Future = asyncio.get_event_loop().create_future() + self._tunnel_conns[tid] = {"writer": writer, "opened": opened} + await self._send_to_rvs({ + "type": "sat_tunnel_open", + "payload": {"satellite": satellite, "tunnelId": tid, + "target": target, "port": port}, + "timestamp": int(time.time() * 1000), + }) + try: + res = await asyncio.wait_for(opened, timeout=20) + except asyncio.TimeoutError: + res = {"ok": False, "error": "Satellit antwortet nicht"} + if not res.get("ok"): + logger.warning("[tunnel] %s:%d nicht erreichbar: %s", + target, port, res.get("error")) + self._tunnel_conns.pop(tid, None) + try: + writer.close() + except Exception: + pass + return + try: + while True: + data = await reader.read(32768) + if not data: + break + await self._send_to_rvs({ + "type": "sat_tunnel_data", + "payload": {"tunnelId": tid, + "b64": base64.b64encode(data).decode("ascii")}, + "timestamp": int(time.time() * 1000), + }) + except Exception: + pass + finally: + self._tunnel_conns.pop(tid, None) + await self._send_to_rvs({ + "type": "sat_tunnel_close", + "payload": {"satellite": satellite, "tunnelId": tid}, + "timestamp": int(time.time() * 1000), + }) + try: + writer.close() + except Exception: + pass + + try: + server = await asyncio.start_server(on_client, "0.0.0.0", local_port) + except Exception as exc: + return {"ok": False, "error": f"Port {local_port} nicht belegbar: {exc}"} + self._tunnel_listeners[local_port] = { + "server": server, "satellite": satellite, "target": target, "port": port} + logger.info("[tunnel] lokal :%d -> %s/%s:%d", local_port, satellite, target, port) + # Adresse aus Sicht der ANDEREN Container (Brain/Proxy liegen im selben + # Docker-Netz) — damit ARIA weiss, was sie ansprechen soll. + return {"ok": True, "localPort": local_port, + "address": f"aria-bridge:{local_port}", + "satellite": satellite, "target": target, "port": port} + + async def tunnel_stop(self, local_port: int) -> dict: + entry = self._tunnel_listeners.pop(int(local_port), None) + if not entry: + return {"ok": False, "error": f"Auf Port {local_port} laeuft kein Tunnel."} + try: + entry["server"].close() + except Exception: + pass + logger.info("[tunnel] lokal :%s geschlossen", local_port) + return {"ok": True, "localPort": int(local_port)} + + def tunnel_list(self) -> list[dict]: + return [{"localPort": p, "address": f"aria-bridge:{p}", + "satellite": e["satellite"], "target": e["target"], "port": e["port"]} + for p, e in sorted(self._tunnel_listeners.items())] + async def _vnc_open(self, session: str, port: int) -> None: """Oeffnet die TCP-Verbindung zur QEMU-VNC und streamt RFB-Bytes als vnc_data ueber RVS. Bestehende Session wird vorher geschlossen.""" diff --git a/rvs/server.js b/rvs/server.js index 87e57b5..0889637 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -84,6 +84,14 @@ const ALLOWED_TYPES = new Set([ // (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt. "sat_creds_set", "sat_creds_delete", "sat_creds_list", "sat_creds_result", "sat_creds_list_result", + // Port-Tunnel: ARIA erreicht ueber den Satelliten einen beliebigen Port im + // fremden LAN (FreeCAD-MCP, Web-App ...). Die Bridge macht daraus einen + // lokalen Port. sat_tunnel_data laeuft in BEIDE Richtungen — unterschieden + // wird ueber die tunnelId. Plus sat_name_* fuer vergebene Geraetenamen, + // damit ARIA "duffy-desktop" sagen kann statt einer IP. + "sat_tunnel_open", "sat_tunnel_opened", "sat_tunnel_data", + "sat_tunnel_close", "sat_tunnel_closed", + "sat_name_set", "sat_name_result", // Host-Agenten: ein Agent laeuft direkt auf einem Rechner, meldet sich mit // host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/ // screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT. diff --git a/satellite/.env.example b/satellite/.env.example index fee1649..1b1d1e5 100644 --- a/satellite/.env.example +++ b/satellite/.env.example @@ -89,3 +89,25 @@ HTTP_MAX_CHARS=20000 # Default-Body-Ausschnitt (offset/max_chars pro Reque 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 + +# ── Port-Tunnel ────────────────────────────────────────────────────── +# ARIA erreicht ueber den Satelliten einen beliebigen Port im LAN — z.B. ein +# FreeCAD-MCP auf einem Rechner oder eine Web-App, die nur hier lauscht. Die +# Bridge macht daraus einen lokalen Port (aria-bridge:19000...). +# +# DEFAULT AUS: das ist ein Weg in dein Netz, den schaltet man bewusst ein. +TUNNEL_ENABLED=false + +# Welche Ziele sind erlaubt? LEER = nur Geraete, die der Satellit bei der Suche +# auch wirklich gefunden hat (dann aber jeder Port). Damit kann der Tunnel +# nichts ansteuern, was nicht ohnehin im LAN sichtbar ist — insbesondere nichts +# im Internet. Sonst Liste aus host oder host:port, * als Platzhalter: +# TUNNEL_ALLOW=192.168.1.50:8080,192.168.1.60,*:5000 +TUNNEL_ALLOW= + +TUNNEL_MAX=8 # gleichzeitig offene Verbindungen +TUNNEL_IDLE_SEC=900 # ungenutzte Tunnel nach 15 min schliessen + +# Geraete-Namen ("duffy-desktop" statt 192.168.1.47). Schluessel ist die MAC, +# damit ein IP-Wechsel per DHCP den Namen nicht verliert. +DEVICE_NAMES_PATH=/data/device_names.json diff --git a/satellite/satellite.py b/satellite/satellite.py index 71da889..0d0fabe 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -28,6 +28,7 @@ CONTROL_ENABLED + Allowlist. Discovery ist read-only. Keine offenen Ports. from __future__ import annotations import asyncio +import base64 import json import logging import os @@ -139,6 +140,26 @@ CONTROL_ALLOWLIST = [ if a.strip() ] +# ── Port-Tunnel ────────────────────────────────────────────────────── +# +# ARIA sitzt woanders und kommt nicht ins fremde Netz. Der Satellit kann aber +# beides: er sieht das LAN und haelt eine ausgehende Verbindung zum RVS. Also +# reicht er eine TCP-Verbindung durch — wie der VNC-Tunnel, nur fuer beliebige +# Ports. Damit erreicht ARIA z.B. einen FreeCAD-MCP oder eine Web-App, die nur +# im Buero-Netz lauscht. +# +# DEFAULT AUS. Das ist ein Loch in ein fremdes Netz — das schaltet man bewusst +# ein, nicht aus Versehen. +TUNNEL_ENABLED = _env_bool("TUNNEL_ENABLED", False) +# Welche Ziele? Leer = nur Geraete, die der Satellit bei der Suche auch +# wirklich GEFUNDEN hat (beliebiger Port). Damit kann der Tunnel nichts +# ansteuern, was nicht ohnehin im LAN sichtbar ist — insbesondere nichts im +# Internet. Sonst Liste aus "host" oder "host:port", * als Platzhalter: +# TUNNEL_ALLOW=192.168.1.50:8080,192.168.1.60,*:5000 +TUNNEL_ALLOW = [a.strip() for a in os.environ.get("TUNNEL_ALLOW", "").split(",") if a.strip()] +TUNNEL_MAX = int(os.environ.get("TUNNEL_MAX", "8") or 8) +TUNNEL_IDLE_SEC = int(os.environ.get("TUNNEL_IDLE_SEC", "900") or 900) + # 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 @@ -288,9 +309,12 @@ MDNS_TYPES = [ "_androidtvremote2._tcp.local.", # Android TV Remote ] -CAPABILITIES = ["discover"] +CAPABILITIES = ["discover", "name"] if CONTROL_ENABLED: CAPABILITIES += CONTROL_ALLOWLIST +if TUNNEL_ENABLED: + # Macht in Diagnostic/ARIA sichtbar, dass dieser Satellit Ports durchreicht. + CAPABILITIES.append("tunnel") # ─── Netz-Kontext / Selbstdiagnose ────────────────────────────────── @@ -596,6 +620,110 @@ def _merge_devices(*lists) -> list[dict]: return list(by_ip.values()) + loose +# ─── Geraete-Namen ────────────────────────────────────────────────── +# +# "duffy-desktop" ist ansprechbar, "192.168.1.47" nicht. Darum kann Stefan +# jedem gefundenen Geraet einen Namen geben; ARIA adressiert danach. +# +# Schluessel ist die MAC, nicht die IP: per DHCP wandert die IP, die MAC +# bleibt. Nur wenn keine MAC bekannt ist, faellt es auf die IP zurueck. +NAMES_PATH = os.environ.get("DEVICE_NAMES_PATH", "/data/device_names.json") +_names: dict = {} + + +def _names_load() -> None: + global _names + try: + with open(NAMES_PATH) as f: + data = json.load(f) + _names = data if isinstance(data, dict) else {} + except FileNotFoundError: + _names = {} + except Exception as exc: + logger.warning("device_names.json unlesbar (%s) — starte leer", exc) + _names = {} + + +def _names_save() -> None: + try: + os.makedirs(os.path.dirname(NAMES_PATH), exist_ok=True) + tmp = NAMES_PATH + ".tmp" + with open(tmp, "w") as f: + json.dump(_names, f, indent=2) + os.replace(tmp, NAMES_PATH) + except Exception as exc: + logger.warning("device_names.json schreiben fehlgeschlagen: %s", exc) + + +def _name_key(dev: dict) -> str: + mac = (dev.get("mac") or "").strip().lower() + return mac if mac else (dev.get("ip") or "").strip() + + +def _apply_names(devices: list[dict]) -> list[dict]: + """Haengt den vergebenen Namen an jedes Geraet (Feld 'name').""" + for d in devices: + n = _names.get(_name_key(d)) + if n: + d["name"] = n + return devices + + +def _resolve_target(target: str, devices: list[dict]) -> str: + """Name ODER IP ODER Hostname -> IP. + + Erlaubt ARIA, "duffy-desktop" zu sagen statt eine Adresse zu kennen. + Vergleich case-insensitive; eine direkt angegebene IP geht unveraendert + durch. + """ + t = (target or "").strip() + if not t: + return "" + if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", t): + return t + tl = t.lower() + for d in devices: + if (d.get("name") or "").strip().lower() == tl: + return d.get("ip") or "" + # Zweite Runde: Hostname aus der Suche (mDNS liefert oft einen) + for d in devices: + for field in ("hostname", "host", "model"): + if (d.get(field) or "").strip().lower().rstrip(".") == tl: + return d.get("ip") or "" + return "" + + +# ─── Port-Tunnel ──────────────────────────────────────────────────── + +def _tunnel_allowed(ip: str, port: int, devices: list[dict]) -> tuple[bool, str]: + """Darf zu ip:port getunnelt werden? + + Ohne TUNNEL_ALLOW gilt: nur Geraete, die bei der Suche tatsaechlich + gefunden wurden. Damit kann der Tunnel nichts ansteuern, was nicht ohnehin + im LAN sichtbar ist — und schon gar nichts im Internet. + """ + if not TUNNEL_ENABLED: + return False, "Tunnel ist an diesem Satelliten aus (TUNNEL_ENABLED=false)." + if not ip: + return False, "Ziel nicht aufloesbar (Name unbekannt?)." + if not (0 < port < 65536): + return False, f"Port {port} ist ungueltig." + if TUNNEL_ALLOW: + for rule in TUNNEL_ALLOW: + rhost, _, rport = rule.partition(":") + if rhost not in ("*", ip): + continue + if rport and rport != "*" and rport != str(port): + continue + return True, "" + return False, f"{ip}:{port} steht nicht in TUNNEL_ALLOW." + known = {d.get("ip") for d in devices if d.get("ip")} + if ip in known: + return True, "" + return False, (f"{ip} wurde im Netz nicht gefunden. Erst 'Geraete suchen', " + f"oder {ip} in TUNNEL_ALLOW eintragen.") + + # ─── Control ──────────────────────────────────────────────────────── async def _control(action: str, params: dict, devices: list[dict]) -> dict: @@ -1630,6 +1758,8 @@ class Satellite: self._devices: list[dict] = [] self._devices_ts: float = 0.0 self._scanning = False + # Offene Port-Tunnel: tunnelId -> {"writer", "task", "last", "ziel"} + self._tunnels: dict[str, dict] = {} async def _scan(self, force: bool = False) -> list[dict]: fresh = (time.time() - self._devices_ts) < DEVICE_CACHE_TTL_SEC @@ -1648,7 +1778,7 @@ class Satellite: 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 = _apply_names(_merge_devices(mdns, ssdp, arp)) if SNMP_DISCOVERY: await self._enrich_snmp(self._devices) await self._enrich_adb(self._devices) @@ -1739,6 +1869,110 @@ class Satellite: logger.info("sat_hello gesendet: id=%s location=%s caps=%s", SATELLITE_ID, SATELLITE_LOCATION, CAPABILITIES) + # ── Port-Tunnel ────────────────────────────────────────────────── + + async def _tunnel_open(self, payload: dict) -> None: + """Oeffnet eine TCP-Verbindung ins LAN und streamt sie zur Bridge. + + Gegenstueck: die Bridge haelt einen lokalen Lauscher, damit ARIA ganz + normal gegen eine Adresse sprechen kann (z.B. http://aria-bridge:19001). + """ + tid = (payload.get("tunnelId") or "").strip() + if not tid: + return + target = (payload.get("target") or payload.get("host") or "").strip() + try: + port = int(payload.get("port") or 0) + except (TypeError, ValueError): + port = 0 + + async def fail(msg: str) -> None: + await self._send({ + "type": "sat_tunnel_opened", + "payload": {"tunnelId": tid, "satellite": SATELLITE_ID, + "ok": False, "error": msg}, + "timestamp": int(time.time() * 1000), + }) + + if len(self._tunnels) >= TUNNEL_MAX: + await fail(f"Zu viele offene Tunnel ({TUNNEL_MAX}).") + return + devices = await self._scan() + ip = _resolve_target(target, devices) + ok, why = _tunnel_allowed(ip, port, devices) + if not ok: + await fail(why) + return + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(ip, port), timeout=10) + except Exception as exc: + await fail(f"Verbindung zu {ip}:{port} fehlgeschlagen: {exc}") + return + + task = asyncio.create_task(self._tunnel_reader(tid, reader)) + self._tunnels[tid] = {"writer": writer, "task": task, + "last": time.time(), "ziel": f"{ip}:{port}"} + logger.info("Tunnel auf: %s -> %s:%d (%s)", tid[:8], ip, port, target or ip) + await self._send({ + "type": "sat_tunnel_opened", + "payload": {"tunnelId": tid, "satellite": SATELLITE_ID, "ok": True, + "ip": ip, "port": port, "target": target}, + "timestamp": int(time.time() * 1000), + }) + + async def _tunnel_reader(self, tid: str, reader: asyncio.StreamReader) -> None: + """LAN -> RVS. Endet, wenn die Gegenstelle zumacht.""" + try: + while True: + data = await reader.read(32768) + if not data: + break + t = self._tunnels.get(tid) + if t: + t["last"] = time.time() + await self._send({ + "type": "sat_tunnel_data", + "payload": {"tunnelId": tid, + "b64": base64.b64encode(data).decode("ascii")}, + "timestamp": int(time.time() * 1000), + }) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.info("Tunnel %s Lesefehler: %s", tid[:8], exc) + finally: + await self._tunnel_close(tid, reason="Gegenstelle hat geschlossen") + + async def _tunnel_close(self, tid: str, reason: str = "") -> None: + t = self._tunnels.pop(tid, None) + if not t: + return + try: + t["task"].cancel() + except Exception: + pass + try: + t["writer"].close() + except Exception: + pass + logger.info("Tunnel zu: %s (%s) %s", tid[:8], t.get("ziel", "?"), reason) + await self._send({ + "type": "sat_tunnel_closed", + "payload": {"tunnelId": tid, "satellite": SATELLITE_ID, "reason": reason}, + "timestamp": int(time.time() * 1000), + }) + + async def _tunnel_reaper(self) -> None: + """Raeumt Tunnel weg, durch die lange nichts mehr geht. Ohne das bleibt + eine vergessene Verbindung offen, bis der Satellit neu startet.""" + while True: + await asyncio.sleep(60) + now = time.time() + for tid in [k for k, v in self._tunnels.items() + if now - v.get("last", now) > TUNNEL_IDLE_SEC]: + await self._tunnel_close(tid, reason=f"seit {TUNNEL_IDLE_SEC}s ungenutzt") + def _for_me(self, payload: dict) -> bool: target = (payload.get("satellite") or "").strip().lower() if not target or target in ("all", "*"): @@ -1766,6 +2000,50 @@ class Satellite: "timestamp": int(time.time() * 1000), }) + elif mtype == "sat_tunnel_open": + if not self._for_me(payload): + return + await self._tunnel_open(payload) + + elif mtype == "sat_tunnel_data": + # Kein _for_me: die tunnelId ist eindeutig, und ein fremder + # Satellit kennt sie nicht. + tid = (payload.get("tunnelId") or "").strip() + t = self._tunnels.get(tid) + if t: + try: + t["writer"].write(base64.b64decode(payload.get("b64") or "")) + t["last"] = time.time() + except Exception as exc: + logger.warning("Tunnel %s schreiben fehlgeschlagen: %s", tid[:8], exc) + await self._tunnel_close(tid, reason=str(exc)[:80]) + + elif mtype == "sat_tunnel_close": + await self._tunnel_close((payload.get("tunnelId") or "").strip(), + reason="von ARIA geschlossen") + + elif mtype == "sat_name_set": + if not self._for_me(payload): + return + key = (payload.get("mac") or payload.get("ip") or "").strip().lower() + name = (payload.get("name") or "").strip()[:60] + req_id = payload.get("requestId", "") + if key: + if name: + _names[key] = name + else: + _names.pop(key, None) + _names_save() + _apply_names(self._devices) + logger.info("Geraetename: %s -> %r", key, name or "(entfernt)") + await self._send({ + "type": "sat_name_result", + "payload": {"requestId": req_id, "satellite": SATELLITE_ID, + "ok": bool(key), "key": key, "name": name, + "error": "" if key else "mac oder ip noetig"}, + "timestamp": int(time.time() * 1000), + }) + elif mtype == "sat_command": if not self._for_me(payload): return @@ -1866,7 +2144,10 @@ class Satellite: logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).") return _creds_load() + _names_load() asyncio.create_task(self._periodic_scan()) + if TUNNEL_ENABLED: + asyncio.create_task(self._tunnel_reaper()) backoff = 1 # use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK), # danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback). Bei TLS+RVS_SNI