Desktop-Agenten (Win/Linux/Mac) koennen jetzt bedienen, nicht nur sehen — mit DENSELBEN Actions/Brain-Tools wie Android (ui_tap/ui_text/ui_key/ui_swipe/ app_launch), ein Werkzeugset fuer Handy und Rechner. - host_agent.py: _gui_input_method() erkennt X11(xdotool)/Wayland(ydotool)/ macOS(osascript+cliclick)/Windows(PowerShell). CAPS werden dynamisch erweitert -> reiner Terminal-Server (kein DISPLAY) meldet KEINE ui_*-Caps. _do_ui_tap/ text/key/swipe + _do_app_launch je Methode; info liefert 'gui'. - Brain: Tool-Beschreibungen decken Desktop ab (Koordinaten aus dem Screenshot, kein ui_dump; button/double bei tap; command bei app_launch); _UI_ACTIONS reicht die neuen Params durch. - README: Fähigkeiten/Plattform-Tabelle + Helfer je OS + HiDPI-Hinweis. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
740 lines
32 KiB
Python
740 lines
32 KiB
Python
"""
|
|
ARIA Host-Agent — Direktzugriff auf EINEN Rechner.
|
|
|
|
Laeuft direkt auf dem Ziel-Rechner (Linux) und verbindet sich AUSGEHEND als
|
|
RVS-Client in Stefans Raum (gleicher Token). Damit kann ARIA diesen Rechner
|
|
direkt steuern, auch wenn er sonst aus dem Netz nicht erreichbar ist (hinter
|
|
NAT/Firewall, kein offener Port). Anders als der Satellit (der ein LAN
|
|
entdeckt/steuert) ist beim Agent das "Geraet" der Rechner selbst.
|
|
|
|
Als reine Binary verteilbar (PyInstaller onefile) + .env fuer die Zugangsdaten.
|
|
|
|
Faehigkeiten (host_command → host_result):
|
|
exec Shell-Kommando ausfuehren (optional sudo)
|
|
read/write Datei lesen/schreiben (Base64)
|
|
info OS / CPU / RAM / Disk / Uptime / Netz
|
|
screenshot Bildschirmfoto (X11/Wayland, wenn grafische Session da ist)
|
|
|
|
Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token) und nur, wenn
|
|
CONTROL_ENABLED=true. Alles wird geloggt. Keine offenen Ports.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import websockets
|
|
|
|
# ─── Plattform-Weichen (Linux / macOS / Windows) ────────────────────
|
|
IS_WINDOWS = os.name == "nt"
|
|
IS_MAC = sys.platform == "darwin"
|
|
|
|
|
|
def _is_admin() -> bool:
|
|
"""root (Unix) bzw. Administrator (Windows)."""
|
|
try:
|
|
return os.geteuid() == 0 # Unix (Linux/macOS)
|
|
except AttributeError:
|
|
try:
|
|
import ctypes
|
|
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # Windows
|
|
except Exception:
|
|
return False
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
|
|
)
|
|
logger = logging.getLogger("host-agent")
|
|
|
|
|
|
def _load_dotenv() -> None:
|
|
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
|
|
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
# PyInstaller-onefile: __file__ liegt im Temp-Extract-Dir, NICHT beim .exe/
|
|
# Binary — deshalb zusaetzlich sys.executable-Ordner (echter Binary-Ort) und
|
|
# das CWD (z.B. der ProgramData-Ordner, den der Windows-Dienst als AppDir nutzt).
|
|
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
|
seen = set()
|
|
candidates = [os.path.join(d, ".env") for d in (exe_dir, here, os.getcwd())]
|
|
for path in [p for p in candidates if not (p in seen or seen.add(p))]:
|
|
if not os.path.isfile(path):
|
|
continue
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, val = line.partition("=")
|
|
key = key.strip()
|
|
if key.startswith("export "):
|
|
key = key[len("export "):].strip()
|
|
val = val.strip()
|
|
if val[:1] in ("'", '"'):
|
|
q = val[0]
|
|
end = val.find(q, 1)
|
|
val = val[1:end] if end != -1 else val[1:]
|
|
else:
|
|
m = re.search(r"\s+#", val)
|
|
if m:
|
|
val = val[:m.start()]
|
|
val = val.strip()
|
|
if key and key not in os.environ:
|
|
os.environ[key] = val
|
|
except Exception as exc:
|
|
logger.warning(".env laden fehlgeschlagen (%s): %s", path, exc)
|
|
break
|
|
|
|
|
|
_load_dotenv()
|
|
|
|
|
|
# ─── Konfiguration ──────────────────────────────────────────────────
|
|
|
|
def _env_bool(name: str, default: bool) -> bool:
|
|
v = os.environ.get(name)
|
|
if v is None:
|
|
return default
|
|
return v.strip().lower() in ("1", "true", "yes", "on", "ja")
|
|
|
|
|
|
def _default_id() -> str:
|
|
host = socket.gethostname() or "host"
|
|
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower()
|
|
return slug or "host"
|
|
|
|
|
|
RVS_HOST = os.environ.get("RVS_HOST", "")
|
|
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
|
RVS_TLS = _env_bool("RVS_TLS", True)
|
|
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
|
|
# Hilft nur, wenn der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss.
|
|
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
|
|
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
|
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. der
|
|
# Agent laeuft im selben Netz wie der RVS und verbindet direkt auf die interne IP,
|
|
# das Caddy-Zertifikat gilt aber fuer den Namen. Dann: RVS_HOST=<interne-ip>,
|
|
# RVS_SNI=<zert-name>. Leer = SNI = RVS_HOST (Normalfall).
|
|
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
|
|
|
|
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
|
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
|
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
|
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
|
if RVS_TLS and RVS_SNI:
|
|
import socket as _socket
|
|
_orig_getaddrinfo = _socket.getaddrinfo
|
|
def _sni_getaddrinfo(host, *a, **k):
|
|
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
|
_socket.getaddrinfo = _sni_getaddrinfo
|
|
|
|
|
|
HOST_ID = (os.environ.get("HOST_ID") or _default_id()).strip()
|
|
HOST_NAME = (os.environ.get("HOST_NAME") or HOST_ID).strip()
|
|
|
|
# Steuerung ist der Sinn des Agents — aber bewusst opt-in (Sicherheit).
|
|
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
|
|
|
# sudo: 1) Agent laeuft als root -> direkt. 2) SUDO_PASSWORD gesetzt -> sudo -S.
|
|
# 3) SUDO_NOPASSWD=true (Live-ISO / NOPASSWD-sudoers) -> sudo -n. 4) sonst Fehler.
|
|
SUDO_PASSWORD = os.environ.get("SUDO_PASSWORD", "")
|
|
SUDO_NOPASSWD = _env_bool("SUDO_NOPASSWD", False)
|
|
|
|
EXEC_TIMEOUT = float(os.environ.get("EXEC_TIMEOUT", "60") or "60")
|
|
# Ausgabe-Fenster (wie beim Satelliten): grosse stdout klein halten fuers RVS.
|
|
OUT_MAX_CHARS = int(os.environ.get("OUT_MAX_CHARS", "20000") or "20000")
|
|
OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "200000")
|
|
# Datei-Transfer-Limit (Base64 durchs RVS).
|
|
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
|
|
|
# Version (wird von release_agent.sh beim Release gesetzt).
|
|
AGENT_VERSION = "0.0.0.7"
|
|
|
|
HEARTBEAT_SEC = 25
|
|
|
|
|
|
def _gui_input_method() -> str:
|
|
"""Welche Methode zur GUI-Steuerung (Maus/Tastatur) ist verfuegbar?
|
|
'' = keine (z.B. Linux-Server ohne X/Wayland -> nur Terminal/exec).
|
|
windows -> PowerShell (Bordmittel)
|
|
macos -> osascript (Text/Tasten) + cliclick (Maus, falls installiert)
|
|
xdotool -> Linux/X11
|
|
ydotool -> Linux/Wayland (Daemon noetig)"""
|
|
if IS_WINDOWS:
|
|
return "windows"
|
|
if IS_MAC:
|
|
return "macos"
|
|
if os.environ.get("DISPLAY") and shutil.which("xdotool"):
|
|
return "xdotool"
|
|
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("ydotool"):
|
|
return "ydotool"
|
|
return ""
|
|
|
|
|
|
GUI_METHOD = _gui_input_method()
|
|
# macOS: Maus braucht cliclick; Text/Tasten/App gehen per osascript (Bordmittel).
|
|
_MAC_MOUSE = IS_MAC and shutil.which("cliclick") is not None
|
|
|
|
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
|
if GUI_METHOD:
|
|
# Tastatur/Text/App gehen bei jeder GUI-Methode; Maus (tap/swipe) auf dem Mac
|
|
# nur mit cliclick.
|
|
CAPS += ["ui_text", "ui_key", "app_launch"]
|
|
if GUI_METHOD != "macos" or _MAC_MOUSE:
|
|
CAPS += ["ui_tap", "ui_swipe"]
|
|
|
|
|
|
# ─── Text-/Zahl-Helfer ──────────────────────────────────────────────
|
|
|
|
def _to_int(s):
|
|
try:
|
|
return int(str(s).strip())
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _to_float(s):
|
|
try:
|
|
return float(str(s).strip())
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _window_text(text: str, params: dict) -> dict:
|
|
"""contains-Zeilenfilter + offset/max_chars-Fenster. Gibt body + Metadaten."""
|
|
total = len(text)
|
|
contains = params.get("contains")
|
|
if contains:
|
|
terms = [contains] if isinstance(contains, str) else list(contains)
|
|
terms = [str(t).lower() for t in terms if str(t).strip()]
|
|
if terms:
|
|
text = "\n".join(ln for ln in text.splitlines()
|
|
if any(t in ln.lower() for t in terms))
|
|
offset = max(0, _to_int(params.get("offset")) or 0)
|
|
max_chars = _to_int(params.get("max_chars")) or OUT_MAX_CHARS
|
|
max_chars = max(1, min(max_chars, OUT_MAX_CHARS_HARD))
|
|
body = text[offset:offset + max_chars]
|
|
return {"body": body, "total_chars": total, "filtered": bool(contains),
|
|
"offset": offset, "returned_chars": len(body),
|
|
"truncated": offset + len(body) < len(text)}
|
|
|
|
|
|
# ─── Aktionen ───────────────────────────────────────────────────────
|
|
|
|
def _wrap_sudo(cmd: str, use_sudo: bool):
|
|
"""Baut die Argv (OS-abhaengige Shell) + optional Root/Admin-Rechte.
|
|
Gibt (argv, stdin_data) oder (None, fehlertext)."""
|
|
if IS_WINDOWS:
|
|
# PowerShell; kein sudo. Fuer Admin-Rechte muss der Agent SELBST als
|
|
# Administrator laufen (UAC) — dann hat 'sudo:true' bereits volle Rechte.
|
|
if use_sudo and not _is_admin():
|
|
return None, ("Windows kennt kein sudo. Starte den Agent als "
|
|
"Administrator ('Als Administrator ausfuehren'), dann "
|
|
"laufen Kommandos mit vollen Rechten.")
|
|
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], None
|
|
# Unix: Linux + macOS (bash vorhanden; macOS-sudo verhaelt sich wie Linux)
|
|
if not use_sudo or _is_admin():
|
|
return ["bash", "-lc", cmd], None
|
|
if SUDO_PASSWORD:
|
|
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
|
|
if SUDO_NOPASSWD:
|
|
return ["sudo", "-n", "bash", "-lc", cmd], None
|
|
return None, ("sudo verlangt ein Passwort. Setze SUDO_PASSWORD in der .env, "
|
|
"oder SUDO_NOPASSWD=true (Live-ISO / passwortloses sudo), oder "
|
|
"starte den Agent als root.")
|
|
|
|
|
|
def _do_exec(params: dict) -> dict:
|
|
cmd = params.get("cmd") or params.get("command") or ""
|
|
if not cmd:
|
|
return {"ok": False, "error": "cmd (Kommando) erforderlich."}
|
|
argv, stdin_data = _wrap_sudo(cmd, bool(params.get("sudo")))
|
|
if argv is None:
|
|
return {"ok": False, "error": stdin_data}
|
|
timeout = _to_float(params.get("timeout")) or EXEC_TIMEOUT
|
|
try:
|
|
r = subprocess.run(argv, input=stdin_data, capture_output=True,
|
|
text=True, timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
return {"ok": False, "error": f"Timeout nach {timeout:.0f}s."}
|
|
except Exception as exc:
|
|
return {"ok": False, "error": f"exec fehlgeschlagen: {exc}"}
|
|
win = _window_text(r.stdout, params)
|
|
return {"ok": True, "result": {"exit_code": r.returncode,
|
|
"stderr": (r.stderr or "")[:4000], **win}}
|
|
|
|
|
|
def _do_read(params: dict) -> dict:
|
|
path = params.get("path") or ""
|
|
if not path:
|
|
return {"ok": False, "error": "path erforderlich."}
|
|
p = Path(path).expanduser()
|
|
if not p.is_file():
|
|
return {"ok": False, "error": f"Datei nicht gefunden: {path}"}
|
|
size = p.stat().st_size
|
|
offset = max(0, _to_int(params.get("offset")) or 0)
|
|
max_bytes = _to_int(params.get("max_bytes")) or FILE_MAX_BYTES
|
|
max_bytes = max(1, min(max_bytes, FILE_MAX_BYTES))
|
|
try:
|
|
with p.open("rb") as f:
|
|
f.seek(offset)
|
|
data = f.read(max_bytes)
|
|
except Exception as exc:
|
|
return {"ok": False, "error": f"Lesen fehlgeschlagen: {exc}"}
|
|
return {"ok": True, "result": {
|
|
"path": str(p), "size": size, "offset": offset,
|
|
"returned_bytes": len(data), "truncated": offset + len(data) < size,
|
|
"base64": base64.b64encode(data).decode("ascii"),
|
|
}}
|
|
|
|
|
|
def _do_write(params: dict) -> dict:
|
|
path = params.get("path") or ""
|
|
if not path:
|
|
return {"ok": False, "error": "path erforderlich."}
|
|
b64 = params.get("base64")
|
|
text = params.get("text")
|
|
if b64 is None and text is None:
|
|
return {"ok": False, "error": "base64 ODER text erforderlich."}
|
|
try:
|
|
data = base64.b64decode(b64) if b64 is not None else str(text).encode("utf-8")
|
|
except Exception as exc:
|
|
return {"ok": False, "error": f"base64 ungueltig: {exc}"}
|
|
p = Path(path).expanduser()
|
|
try:
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
mode = "ab" if params.get("append") else "wb"
|
|
with p.open(mode) as f:
|
|
f.write(data)
|
|
if params.get("chmod"):
|
|
os.chmod(p, int(str(params["chmod"]), 8))
|
|
except Exception as exc:
|
|
return {"ok": False, "error": f"Schreiben fehlgeschlagen: {exc}"}
|
|
return {"ok": True, "result": {"path": str(p), "bytes": len(data)}}
|
|
|
|
|
|
def _do_info(params: dict) -> dict:
|
|
info = {
|
|
"host": HOST_NAME, "hostname": socket.gethostname(),
|
|
"agent_version": AGENT_VERSION,
|
|
"os": platform.platform(), "kernel": platform.release(),
|
|
"arch": platform.machine(), "python": platform.python_version(),
|
|
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
|
|
"is_root": _is_admin(),
|
|
"gui": GUI_METHOD or "none", # '' => nur Terminal, keine GUI-Steuerung
|
|
}
|
|
try:
|
|
import psutil
|
|
info["cpu_percent"] = psutil.cpu_percent(interval=0.3)
|
|
info["cpu_count"] = psutil.cpu_count()
|
|
vm = psutil.virtual_memory()
|
|
info["ram_used_mb"] = round(vm.used / 1024 / 1024)
|
|
info["ram_total_mb"] = round(vm.total / 1024 / 1024)
|
|
info["ram_percent"] = vm.percent
|
|
du = psutil.disk_usage("/")
|
|
info["disk_used_gb"] = round(du.used / 1024 / 1024 / 1024, 1)
|
|
info["disk_total_gb"] = round(du.total / 1024 / 1024 / 1024, 1)
|
|
info["disk_percent"] = du.percent
|
|
info["uptime_s"] = round(time.time() - psutil.boot_time())
|
|
info["load_avg"] = list(os.getloadavg()) if hasattr(os, "getloadavg") else None
|
|
except Exception:
|
|
# Fallback ohne psutil: das Noetigste aus os/shutil.
|
|
try:
|
|
info["load_avg"] = list(os.getloadavg())
|
|
except Exception:
|
|
info["load_avg"] = None
|
|
try:
|
|
total, used, free = shutil.disk_usage("/")
|
|
info["disk_used_gb"] = round(used / 1024 ** 3, 1)
|
|
info["disk_total_gb"] = round(total / 1024 ** 3, 1)
|
|
except Exception:
|
|
pass
|
|
# Primaere IP (best effort).
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.connect(("8.8.8.8", 80))
|
|
info["primary_ip"] = s.getsockname()[0]
|
|
s.close()
|
|
except Exception:
|
|
info["primary_ip"] = ""
|
|
return {"ok": True, "result": info}
|
|
|
|
|
|
def _do_screenshot(params: dict) -> dict:
|
|
"""Bildschirmfoto — OS-abhaengig. Windows: PowerShell/System.Drawing;
|
|
macOS: screencapture; Linux: grim (Wayland) / scrot/maim/import (X11).
|
|
Braucht eine aktive grafische Session."""
|
|
import tempfile
|
|
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
|
|
candidates = []
|
|
if IS_WINDOWS:
|
|
ps = ("Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
|
|
"$b=[System.Windows.Forms.SystemInformation]::VirtualScreen;"
|
|
"$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height;"
|
|
"$g=[System.Drawing.Graphics]::FromImage($bmp);"
|
|
"$g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size);"
|
|
f"$bmp.Save('{tmp}');$g.Dispose();$bmp.Dispose()")
|
|
candidates.append(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps])
|
|
elif IS_MAC:
|
|
candidates.append(["screencapture", "-x", tmp]) # -x = ohne Ton
|
|
else:
|
|
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("grim"):
|
|
candidates.append(["grim", tmp])
|
|
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
|
|
("maim", ["maim", tmp]),
|
|
("gnome-screenshot", ["gnome-screenshot", "-f", tmp]),
|
|
("import", ["import", "-window", "root", tmp])):
|
|
if shutil.which(tool):
|
|
candidates.append(argv)
|
|
if not candidates:
|
|
return {"ok": False, "error":
|
|
"Kein Screenshot-Tool gefunden. Linux: grim (Wayland) oder "
|
|
"scrot/maim (X11) installieren. (Windows/macOS nutzen Bordmittel.)"}
|
|
last_err = ""
|
|
for argv in candidates:
|
|
try:
|
|
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
|
|
if r.returncode == 0 and os.path.isfile(tmp) and os.path.getsize(tmp) > 0:
|
|
with open(tmp, "rb") as f:
|
|
b = f.read()
|
|
os.remove(tmp)
|
|
return {"ok": True, "result": {"format": "png", "bytes": len(b),
|
|
"base64": base64.b64encode(b).decode("ascii")}}
|
|
last_err = (r.stderr or r.stdout or "").strip()[:200]
|
|
except Exception as exc:
|
|
last_err = str(exc)[:200]
|
|
return {"ok": False, "error": f"Screenshot fehlgeschlagen ({last_err}). "
|
|
"Laeuft der Agent in derselben grafischen Session?"}
|
|
|
|
|
|
# ─── GUI-Steuerung (Maus/Tastatur/App) — nur mit grafischer Session ──
|
|
|
|
def _gui_unavailable() -> dict:
|
|
return {"ok": False, "error":
|
|
"Keine grafische Session zum Steuern. Linux: X11 (DISPLAY + xdotool) "
|
|
"oder Wayland (WAYLAND_DISPLAY + ydotool-Daemon). macOS: cliclick fuer "
|
|
"Maus. Auf einem reinen Terminal-/Server-System gibt es keine GUI."}
|
|
|
|
|
|
def _run_gui(argv: list, desc: str) -> dict:
|
|
try:
|
|
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
|
|
if r.returncode == 0:
|
|
return {"ok": True, "result": {"message": f"{desc} ausgefuehrt"}}
|
|
return {"ok": False, "error": f"{desc} fehlgeschlagen: "
|
|
+ (r.stderr or r.stdout or f"exit {r.returncode}").strip()[:200]}
|
|
except Exception as exc:
|
|
return {"ok": False, "error": f"{desc}: {exc}"}
|
|
|
|
|
|
def _ps(script: str, desc: str) -> dict:
|
|
return _run_gui(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], desc)
|
|
|
|
|
|
def _osa(script: str, desc: str) -> dict:
|
|
return _run_gui(["osascript", "-e", script], desc)
|
|
|
|
|
|
_WIN_MOUSE_DECL = (
|
|
"Add-Type -Name U -Namespace W -MemberDefinition '"
|
|
"[DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x,int y);"
|
|
"[DllImport(\"user32.dll\")] public static extern void mouse_event("
|
|
"uint f,uint x,uint y,uint d,int e);';"
|
|
)
|
|
|
|
|
|
def _do_ui_tap(params: dict) -> dict:
|
|
if not GUI_METHOD:
|
|
return _gui_unavailable()
|
|
x = _to_int(params.get("x")); y = _to_int(params.get("y"))
|
|
if x is None or y is None:
|
|
return {"ok": False, "error": "ui_tap braucht x,y (Bildschirm-Pixel aus dem Screenshot)."}
|
|
button = str(params.get("button", "left")).lower()
|
|
double = bool(params.get("double"))
|
|
desc = f"Klick ({x},{y})"
|
|
if GUI_METHOD == "xdotool":
|
|
btn = {"left": "1", "middle": "2", "right": "3"}.get(button, "1")
|
|
argv = ["xdotool", "mousemove", "--sync", str(x), str(y), "click"]
|
|
if double:
|
|
argv += ["--repeat", "2", "--delay", "120"]
|
|
return _run_gui(argv + [btn], desc)
|
|
if GUI_METHOD == "ydotool":
|
|
code = {"left": "0xC0", "right": "0xC1", "middle": "0xC2"}.get(button, "0xC0")
|
|
_run_gui(["ydotool", "mousemove", "-a", str(x), str(y)], "move")
|
|
r = _run_gui(["ydotool", "click", code], desc)
|
|
if double and r.get("ok"):
|
|
_run_gui(["ydotool", "click", code], desc)
|
|
return r
|
|
if GUI_METHOD == "macos":
|
|
if not _MAC_MOUSE:
|
|
return {"ok": False, "error": "Maus-Steuerung braucht cliclick (brew install cliclick)."}
|
|
cmd = ("dc:" if double else ("rc:" if button == "right" else "c:")) + f"{x},{y}"
|
|
return _run_gui(["cliclick", cmd], desc)
|
|
if GUI_METHOD == "windows":
|
|
down, up = ("0x0002", "0x0004") if button != "right" else ("0x0008", "0x0010")
|
|
click = f"[W.U]::mouse_event({down},0,0,0,0);[W.U]::mouse_event({up},0,0,0,0);"
|
|
script = _WIN_MOUSE_DECL + f"[W.U]::SetCursorPos({x},{y});" + click + (click if double else "")
|
|
return _ps(script, desc)
|
|
return _gui_unavailable()
|
|
|
|
|
|
def _do_ui_text(params: dict) -> dict:
|
|
if not GUI_METHOD:
|
|
return _gui_unavailable()
|
|
text = str(params.get("text", ""))
|
|
if not text:
|
|
return {"ok": False, "error": "ui_text braucht 'text'."}
|
|
desc = f"Text ({len(text)} Zeichen)"
|
|
if GUI_METHOD == "xdotool":
|
|
return _run_gui(["xdotool", "type", "--clearmodifiers", "--", text], desc)
|
|
if GUI_METHOD == "ydotool":
|
|
return _run_gui(["ydotool", "type", "--", text], desc)
|
|
if GUI_METHOD == "macos":
|
|
esc = text.replace("\\", "\\\\").replace('"', '\\"')
|
|
return _osa(f'tell application "System Events" to keystroke "{esc}"', desc)
|
|
if GUI_METHOD == "windows":
|
|
safe = re.sub(r"([+^%~(){}\[\]])", r"{\1}", text).replace('"', '`"')
|
|
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
|
|
f"[System.Windows.Forms.SendKeys]::SendWait(\"{safe}\")", desc)
|
|
return _gui_unavailable()
|
|
|
|
|
|
def _do_ui_key(params: dict) -> dict:
|
|
if not GUI_METHOD:
|
|
return _gui_unavailable()
|
|
key = str(params.get("key", "")).strip()
|
|
if not key:
|
|
return {"ok": False, "error": "ui_key braucht 'key' (z.B. Return, Escape, ctrl+c)."}
|
|
desc = f"Taste '{key}'"
|
|
if GUI_METHOD == "xdotool":
|
|
return _run_gui(["xdotool", "key", "--clearmodifiers", key], desc)
|
|
if GUI_METHOD == "ydotool":
|
|
return _run_gui(["ydotool", "key", key], desc) # best effort (Keycodes)
|
|
if GUI_METHOD == "macos":
|
|
codes = {"return": 36, "enter": 36, "tab": 48, "space": 49, "delete": 51,
|
|
"escape": 53, "esc": 53, "left": 123, "right": 124, "down": 125, "up": 126,
|
|
"home": 115, "end": 119, "pageup": 116, "pagedown": 121}
|
|
k = key.lower()
|
|
if k in codes:
|
|
return _osa(f'tell application "System Events" to key code {codes[k]}', desc)
|
|
return _osa(f'tell application "System Events" to keystroke "{key}"', desc)
|
|
if GUI_METHOD == "windows":
|
|
m = {"return": "{ENTER}", "enter": "{ENTER}", "escape": "{ESC}", "esc": "{ESC}",
|
|
"tab": "{TAB}", "backspace": "{BACKSPACE}", "delete": "{DEL}",
|
|
"up": "{UP}", "down": "{DOWN}", "left": "{LEFT}", "right": "{RIGHT}",
|
|
"home": "{HOME}", "end": "{END}", "pageup": "{PGUP}", "pagedown": "{PGDN}"}
|
|
send = m.get(key.lower(), key)
|
|
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
|
|
f"[System.Windows.Forms.SendKeys]::SendWait('{send}')", desc)
|
|
return _gui_unavailable()
|
|
|
|
|
|
def _do_ui_swipe(params: dict) -> dict:
|
|
"""Auf dem Desktop = Maus ziehen von (x1,y1) nach (x2,y2)."""
|
|
if not GUI_METHOD:
|
|
return _gui_unavailable()
|
|
x1 = _to_int(params.get("x1")); y1 = _to_int(params.get("y1"))
|
|
x2 = _to_int(params.get("x2")); y2 = _to_int(params.get("y2"))
|
|
if None in (x1, y1, x2, y2):
|
|
return {"ok": False, "error": "ui_swipe braucht x1,y1,x2,y2."}
|
|
desc = f"Ziehen ({x1},{y1} -> {x2},{y2})"
|
|
if GUI_METHOD == "xdotool":
|
|
return _run_gui(["xdotool", "mousemove", "--sync", str(x1), str(y1),
|
|
"mousedown", "1", "mousemove", "--sync", str(x2), str(y2),
|
|
"mouseup", "1"], desc)
|
|
if GUI_METHOD == "ydotool":
|
|
_run_gui(["ydotool", "mousemove", "-a", str(x1), str(y1)], "move")
|
|
_run_gui(["ydotool", "click", "0x40"], "down")
|
|
_run_gui(["ydotool", "mousemove", "-a", str(x2), str(y2)], "move")
|
|
return _run_gui(["ydotool", "click", "0x80"], desc)
|
|
if GUI_METHOD == "macos":
|
|
if not _MAC_MOUSE:
|
|
return {"ok": False, "error": "Ziehen braucht cliclick."}
|
|
return _run_gui(["cliclick", f"dd:{x1},{y1}", f"du:{x2},{y2}"], desc)
|
|
if GUI_METHOD == "windows":
|
|
script = (_WIN_MOUSE_DECL +
|
|
f"[W.U]::SetCursorPos({x1},{y1});[W.U]::mouse_event(0x0002,0,0,0,0);"
|
|
f"Start-Sleep -Milliseconds 80;[W.U]::SetCursorPos({x2},{y2});"
|
|
"[W.U]::mouse_event(0x0004,0,0,0,0);")
|
|
return _ps(script, desc)
|
|
return _gui_unavailable()
|
|
|
|
|
|
def _do_app_launch(params: dict) -> dict:
|
|
"""Startet eine App/ein Programm (nicht-blockierend). 'app' = Name/Pfad,
|
|
'command' = beliebiger Startbefehl."""
|
|
app = str(params.get("app") or params.get("query") or "").strip()
|
|
command = str(params.get("command") or "").strip()
|
|
if not app and not command:
|
|
return {"ok": False, "error": "app_launch braucht 'app' (Name) oder 'command'."}
|
|
try:
|
|
if command:
|
|
argv = command if IS_WINDOWS else ["/bin/sh", "-c", command]
|
|
subprocess.Popen(argv, shell=bool(IS_WINDOWS),
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
return {"ok": True, "result": {"message": f"Befehl gestartet: {command}"}}
|
|
if IS_MAC:
|
|
subprocess.Popen(["open", "-a", app])
|
|
elif IS_WINDOWS:
|
|
subprocess.Popen(["cmd", "/c", "start", "", app])
|
|
else:
|
|
launcher = shutil.which(app) or app
|
|
head = ["setsid"] if shutil.which("setsid") else []
|
|
subprocess.Popen(head + [launcher],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
return {"ok": True, "result": {"message": f"App gestartet: {app}"}}
|
|
except Exception as exc:
|
|
return {"ok": False, "error": f"Start fehlgeschlagen: {exc}"}
|
|
|
|
|
|
ACTIONS = {
|
|
"exec": _do_exec, "read": _do_read, "write": _do_write,
|
|
"info": _do_info, "screenshot": _do_screenshot,
|
|
"ui_tap": _do_ui_tap, "ui_text": _do_ui_text, "ui_key": _do_ui_key,
|
|
"ui_swipe": _do_ui_swipe, "app_launch": _do_app_launch,
|
|
}
|
|
|
|
|
|
# ─── RVS-Client ─────────────────────────────────────────────────────
|
|
|
|
class HostAgent:
|
|
def __init__(self) -> None:
|
|
self.ws = None
|
|
|
|
def _for_me(self, payload: dict) -> bool:
|
|
"""Command gilt uns, wenn kein host-Feld gesetzt ist (Broadcast) oder es
|
|
auf unsere ID/Name passt."""
|
|
target = (payload.get("host") or payload.get("hostId") or "").strip()
|
|
if not target:
|
|
return True
|
|
return target.lower() in (HOST_ID.lower(), HOST_NAME.lower())
|
|
|
|
async def _send(self, message: dict) -> None:
|
|
if self.ws is None:
|
|
return
|
|
try:
|
|
await self.ws.send(json.dumps(message))
|
|
except Exception as exc:
|
|
logger.warning("Senden fehlgeschlagen: %s", exc)
|
|
|
|
async def _hello(self, log: bool = False) -> None:
|
|
if log:
|
|
logger.info("host_hello: id=%s name=%s caps=%s control=%s",
|
|
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
|
|
await self._send({"type": "host_hello", "payload": {
|
|
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
|
|
"version": AGENT_VERSION, "caps": CAPS, "control": CONTROL_ENABLED,
|
|
}, "timestamp": int(time.time() * 1000)})
|
|
|
|
async def _heartbeat(self) -> None:
|
|
while True:
|
|
await asyncio.sleep(HEARTBEAT_SEC)
|
|
await self._send({"type": "host_ping", "payload": {"hostId": HOST_ID},
|
|
"timestamp": int(time.time() * 1000)})
|
|
await self._hello()
|
|
|
|
async def _handle(self, raw: str) -> None:
|
|
try:
|
|
msg = json.loads(raw)
|
|
except Exception:
|
|
return
|
|
if msg.get("type") != "host_command":
|
|
return
|
|
payload = msg.get("payload") or {}
|
|
if not self._for_me(payload):
|
|
return
|
|
req_id = payload.get("requestId", "")
|
|
action = (payload.get("action") or "").strip()
|
|
params = payload.get("params") or {}
|
|
if not CONTROL_ENABLED:
|
|
result = {"ok": False, "error": "Steuerung ist deaktiviert (CONTROL_ENABLED=false)."}
|
|
elif action not in ACTIONS:
|
|
result = {"ok": False, "error": f"Aktion '{action}' unbekannt (bekannt: {', '.join(ACTIONS)})."}
|
|
else:
|
|
logger.info("[cmd] %s params=%s", action,
|
|
{k: str(v)[:60] for k, v in params.items() if k not in ("base64",)})
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
result = await loop.run_in_executor(None, ACTIONS[action], params)
|
|
except Exception as exc:
|
|
result = {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
|
await self._send({"type": "host_result",
|
|
"payload": {"requestId": req_id, "hostId": HOST_ID,
|
|
"action": action, **result},
|
|
"timestamp": int(time.time() * 1000)})
|
|
|
|
async def run(self) -> None:
|
|
if not RVS_HOST or not RVS_TOKEN:
|
|
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
|
return
|
|
backoff = 1
|
|
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
|
|
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback).
|
|
use_tls = RVS_TLS
|
|
tls_fallback_tried = False
|
|
connect_kwargs = {"max_size": 16 * 1024 * 1024,
|
|
"ping_interval": 20, "ping_timeout": 20}
|
|
while True:
|
|
proto = "wss" if use_tls else "ws"
|
|
# Bei TLS + RVS_SNI: URI nutzt den HOSTNAMEN (Host-Header/SNI/Cert),
|
|
# getaddrinfo mappt ihn auf die IP in RVS_HOST. Bei ws:// direkt die IP.
|
|
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
|
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
|
fallback = False
|
|
try:
|
|
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
|
|
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
|
async with websockets.connect(url, **connect_kwargs) as ws:
|
|
self.ws = ws
|
|
backoff = 1
|
|
tls_fallback_tried = False
|
|
await self._hello(log=True)
|
|
hb = asyncio.create_task(self._heartbeat())
|
|
try:
|
|
async for raw in ws:
|
|
await self._handle(raw)
|
|
finally:
|
|
hb.cancel()
|
|
except Exception as exc:
|
|
logger.warning("RVS-Verbindung verloren: %s", exc)
|
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
|
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
|
|
use_tls = False
|
|
tls_fallback_tried = True
|
|
fallback = True
|
|
finally:
|
|
self.ws = None
|
|
if fallback:
|
|
continue # sofort erneut mit ws://
|
|
await asyncio.sleep(backoff)
|
|
backoff = min(backoff * 2, 30)
|
|
use_tls = RVS_TLS # kein Sticky-Fallback
|
|
tls_fallback_tried = False
|
|
|
|
|
|
def main() -> None:
|
|
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
|
|
HOST_ID, HOST_NAME, CONTROL_ENABLED, _is_admin())
|
|
try:
|
|
asyncio.run(HostAgent().run())
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|