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