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