feat(satellite): Port-Tunnel ins fremde Netz + Geraete-Namen

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) <noreply@anthropic.com>
This commit is contained in:
2026-10-04 18:35:40 +02:00
co-authored by Claude Opus 5
parent ae3a112868
commit cfd17b200a
5 changed files with 622 additions and 2 deletions
+122
View File
@@ -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", "type": "function",
"function": { "function": {
@@ -3111,6 +3171,64 @@ class Agent:
except Exception as exc: except Exception as exc:
return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {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: def _dispatch_host(self, name: str, arguments: dict) -> str:
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt """Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht 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." return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"): if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments) 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", if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play",
"jellyfin_control"): "jellyfin_control"):
return self._dispatch_jellyfin(name, arguments) return self._dispatch_jellyfin(name, arguments)
+187
View File
@@ -765,6 +765,17 @@ class ARIABridge:
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem # "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON). # Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
self._vnc_sessions: dict[str, dict] = {} 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). # VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused. # host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST") self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
@@ -3599,6 +3610,42 @@ class ARIABridge:
asyncio.create_task(self._check_desktop(session, port)) asyncio.create_task(self._check_desktop(session, port))
return 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": elif msg_type == "vnc_open":
session = str(payload.get("session") or "main") session = str(payload.get("session") or "main")
try: try:
@@ -4700,6 +4747,45 @@ class ARIABridge:
elif method in ("GET", "POST") and path == "/internal/worker-list": elif method in ("GET", "POST") and path == "/internal/worker-list":
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte). # Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()}) 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": elif method == "POST" and path == "/internal/host-list":
# Brain fragt: welche Host-Agenten (Rechner) sind online + Capabilities. # Brain fragt: welche Host-Agenten (Rechner) sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "hosts": self._host_list()}) await _send_response(writer, 200, {"ok": True, "hosts": self._host_list()})
@@ -4903,6 +4989,107 @@ class ARIABridge:
"timestamp": int(time.time() * 1000), "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 <satellite>/<target>:<port> 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: async def _vnc_open(self, session: str, port: int) -> None:
"""Oeffnet die TCP-Verbindung zur QEMU-VNC und streamt RFB-Bytes als """Oeffnet die TCP-Verbindung zur QEMU-VNC und streamt RFB-Bytes als
vnc_data ueber RVS. Bestehende Session wird vorher geschlossen.""" vnc_data ueber RVS. Bestehende Session wird vorher geschlossen."""
+8
View File
@@ -84,6 +84,14 @@ const ALLOWED_TYPES = new Set([
// (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt. // (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt.
"sat_creds_set", "sat_creds_delete", "sat_creds_list", "sat_creds_set", "sat_creds_delete", "sat_creds_list",
"sat_creds_result", "sat_creds_list_result", "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-Agenten: ein Agent laeuft direkt auf einem Rechner, meldet sich mit
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/ // host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT. // screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
+22
View File
@@ -89,3 +89,25 @@ HTTP_MAX_CHARS=20000 # Default-Body-Ausschnitt (offset/max_chars pro Reque
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
DISCOVER_TIMEOUT_SEC=6 # Dauer eines Sweeps (mDNS + SSDP) DISCOVER_TIMEOUT_SEC=6 # Dauer eines Sweeps (mDNS + SSDP)
DEVICE_CACHE_TTL_SEC=120 # wie lange ein Inventar als "frisch" gilt 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
+283 -2
View File
@@ -28,6 +28,7 @@ CONTROL_ENABLED + Allowlist. Discovery ist read-only. Keine offenen Ports.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import json import json
import logging import logging
import os import os
@@ -139,6 +140,26 @@ CONTROL_ALLOWLIST = [
if a.strip() 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. # adb.*: Android-Geraete (v.a. Fire TV) im LAN per Netzwerk-Debugging erreichen.
# install dauert lange (APK laden + uebertragen + installieren) -> grosszuegiger # install dauert lange (APK laden + uebertragen + installieren) -> grosszuegiger
# Timeout. APK-Cache in /data (persistentes Volume), damit N Sticks nur EINEN # Timeout. APK-Cache in /data (persistentes Volume), damit N Sticks nur EINEN
@@ -288,9 +309,12 @@ MDNS_TYPES = [
"_androidtvremote2._tcp.local.", # Android TV Remote "_androidtvremote2._tcp.local.", # Android TV Remote
] ]
CAPABILITIES = ["discover"] CAPABILITIES = ["discover", "name"]
if CONTROL_ENABLED: if CONTROL_ENABLED:
CAPABILITIES += CONTROL_ALLOWLIST CAPABILITIES += CONTROL_ALLOWLIST
if TUNNEL_ENABLED:
# Macht in Diagnostic/ARIA sichtbar, dass dieser Satellit Ports durchreicht.
CAPABILITIES.append("tunnel")
# ─── Netz-Kontext / Selbstdiagnose ────────────────────────────────── # ─── Netz-Kontext / Selbstdiagnose ──────────────────────────────────
@@ -596,6 +620,110 @@ def _merge_devices(*lists) -> list[dict]:
return list(by_ip.values()) + loose 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 ──────────────────────────────────────────────────────── # ─── Control ────────────────────────────────────────────────────────
async def _control(action: str, params: dict, devices: list[dict]) -> dict: async def _control(action: str, params: dict, devices: list[dict]) -> dict:
@@ -1630,6 +1758,8 @@ class Satellite:
self._devices: list[dict] = [] self._devices: list[dict] = []
self._devices_ts: float = 0.0 self._devices_ts: float = 0.0
self._scanning = False 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]: async def _scan(self, force: bool = False) -> list[dict]:
fresh = (time.time() - self._devices_ts) < DEVICE_CACHE_TTL_SEC 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) mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC) ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
arp = await loop.run_in_executor(None, _discover_arp) 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: if SNMP_DISCOVERY:
await self._enrich_snmp(self._devices) await self._enrich_snmp(self._devices)
await self._enrich_adb(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", logger.info("sat_hello gesendet: id=%s location=%s caps=%s",
SATELLITE_ID, SATELLITE_LOCATION, CAPABILITIES) 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: def _for_me(self, payload: dict) -> bool:
target = (payload.get("satellite") or "").strip().lower() target = (payload.get("satellite") or "").strip().lower()
if not target or target in ("all", "*"): if not target or target in ("all", "*"):
@@ -1766,6 +2000,50 @@ class Satellite:
"timestamp": int(time.time() * 1000), "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": elif mtype == "sat_command":
if not self._for_me(payload): if not self._for_me(payload):
return return
@@ -1866,7 +2144,10 @@ class Satellite:
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).") logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
return return
_creds_load() _creds_load()
_names_load()
asyncio.create_task(self._periodic_scan()) asyncio.create_task(self._periodic_scan())
if TUNNEL_ENABLED:
asyncio.create_task(self._tunnel_reaper())
backoff = 1 backoff = 1
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK), # 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 # danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback). Bei TLS+RVS_SNI