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",
"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)