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