feat(host-agent): Direktzugriffs-Agent fuer einen Rechner (Phase A: Agent + Build)
Schlanker Agent, der DIREKT auf einem Linux-Rechner laeuft und sich ausgehend zum RVS verbindet -> ARIA steuert den Rechner auch hinter NAT/Firewall, wo er sonst nicht erreichbar ist. Anders als der Satellit (LAN-Gateway) steuert der Agent den Rechner, auf dem er laeuft. Faehigkeiten (host_command -> host_result): exec (opt. sudo), read, write, info (CPU/RAM/Disk/Uptime via psutil), screenshot (grim/scrot/maim/import). sudo-Logik: root -> direkt; SUDO_PASSWORD -> sudo -S; SUDO_NOPASSWD (Live-ISO) -> sudo -n; sonst klare Fehlermeldung. Gate: CONTROL_ENABLED + RVS-Token. stdout wird wie beim Satelliten gefenstert (contains/offset/max_chars). Als portable Onefile-Binary verteilbar (PyInstaller im bullseye-Container fuer breite glibc-Kompatibilitaet): build.sh + Dockerfile.build. Plus .env.example, systemd-Unit und README. Naechste Phasen: RVS ALLOWED_TYPES (host_*), Bridge-Registry + /internal/host*, Brain-Tools host_list/exec/read/write/info/screenshot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# ─── ARIA Host-Agent — Konfiguration ───────────────────────────────
|
||||
# Kopiere diese Datei nach .env (neben die Binary) und passe sie an.
|
||||
|
||||
# RVS-Zugang (identisch zum Haupt-Stack — gleicher Raum/Token, damit ARIA
|
||||
# diesen Rechner erreicht). Werte aus der Haupt-.env.
|
||||
RVS_HOST=rvs.example.de
|
||||
RVS_PORT=443
|
||||
RVS_TLS=true
|
||||
RVS_TOKEN=
|
||||
|
||||
# ─── Identitaet dieses Rechners ────────────────────────────────────
|
||||
# HOST_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||
# HOST_NAME = menschlicher Name, so spricht ARIA den Rechner an ("Stefans Laptop").
|
||||
HOST_ID=
|
||||
HOST_NAME=
|
||||
|
||||
# ─── Steuerung (Sicherheit!) ───────────────────────────────────────
|
||||
# MUSS auf true, sonst fuehrt der Agent nichts aus (reiner Idle-Client).
|
||||
CONTROL_ENABLED=true
|
||||
|
||||
# ─── sudo ──────────────────────────────────────────────────────────
|
||||
# Reihenfolge: 1) Agent laeuft als root -> braucht kein sudo. 2) SUDO_PASSWORD
|
||||
# gesetzt -> sudo -S mit Passwort. 3) SUDO_NOPASSWD=true -> sudo -n (Live-ISO /
|
||||
# passwortloses sudo, z.B. Linux Mint vom Stick). 4) sonst schlaegt sudo fehl.
|
||||
SUDO_PASSWORD=
|
||||
SUDO_NOPASSWD=false
|
||||
|
||||
# ─── Limits (optional) ─────────────────────────────────────────────
|
||||
EXEC_TIMEOUT=60 # max. Laufzeit eines Kommandos (s)
|
||||
OUT_MAX_CHARS=20000 # stdout-Ausschnitt (offset/max_chars pro Request)
|
||||
FILE_MAX_BYTES=10485760 # max. Datei-Transfer (10 MB)
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
__pycache__/
|
||||
@@ -0,0 +1,18 @@
|
||||
# Baut die Host-Agent-Binary mit PyInstaller in einem Container mit ALTEM glibc
|
||||
# (bullseye, glibc 2.31), damit die Onefile-Binary auf moeglichst vielen Linux-
|
||||
# Distributionen laeuft (glibc ist abwaerts-, nicht aufwaertskompatibel).
|
||||
FROM python:3.11-slim-bullseye
|
||||
|
||||
WORKDIR /build
|
||||
RUN pip install --no-cache-dir pyinstaller
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY host_agent.py .
|
||||
|
||||
# Onefile-Binary; psutil-Hidden-Imports werden von PyInstaller erkannt.
|
||||
RUN pyinstaller --onefile --name aria-host-agent \
|
||||
--collect-all psutil \
|
||||
host_agent.py
|
||||
|
||||
# Ergebnis liegt in /build/dist/aria-host-agent
|
||||
CMD ["sh", "-c", "cp /build/dist/aria-host-agent /out/ && echo 'Binary -> /out/aria-host-agent'"]
|
||||
@@ -0,0 +1,62 @@
|
||||
# ARIA Host-Agent
|
||||
|
||||
Ein schlanker Agent, der **direkt auf einem Rechner** läuft und ARIA erlaubt,
|
||||
diesen Rechner zu steuern — auch wenn er sonst aus dem Netz **nicht erreichbar**
|
||||
ist (hinter NAT/Firewall, kein offener Port). Der Agent verbindet sich
|
||||
**ausgehend** zum RVS (gleicher Token wie der Rest von ARIA).
|
||||
|
||||
Unterschied zum **Satelliten**: der Satellit entdeckt und steuert *andere*
|
||||
Geräte in einem LAN; der Host-Agent steuert *den Rechner, auf dem er läuft*.
|
||||
|
||||
## Fähigkeiten
|
||||
|
||||
| Aktion | Was |
|
||||
|--------------|-----|
|
||||
| `exec` | Shell-Kommando ausführen (optional `sudo`), stdout/stderr/exit |
|
||||
| `read` | Datei lesen (Base64, mit Offset/Limit) |
|
||||
| `write` | Datei schreiben/anhängen (Base64 oder Text) |
|
||||
| `info` | OS, CPU/RAM/Disk-Auslastung, Uptime, IP |
|
||||
| `screenshot` | Bildschirmfoto (X11: scrot/maim · Wayland: grim) |
|
||||
|
||||
ARIA nutzt diese über die Brain-Tools `host_list` / `host_exec` / `host_read` /
|
||||
`host_write` / `host_info` / `host_screenshot`.
|
||||
|
||||
## Bauen (portable Binary)
|
||||
|
||||
```bash
|
||||
./build.sh # braucht Docker; erzeugt dist/aria-host-agent (~15 MB)
|
||||
```
|
||||
|
||||
Gebaut wird in einem bullseye-Container (altes glibc), damit die Binary auf
|
||||
möglichst vielen Distributionen läuft.
|
||||
|
||||
## Installieren
|
||||
|
||||
1. `dist/aria-host-agent` auf den Ziel-Rechner kopieren.
|
||||
2. `.env.example` → `.env` daneben, RVS-Zugang + `CONTROL_ENABLED=true` eintragen.
|
||||
3. Starten: `chmod +x aria-host-agent && ./aria-host-agent`
|
||||
— oder als Dienst: siehe `aria-host-agent.service`.
|
||||
|
||||
## sudo
|
||||
|
||||
Vier Fälle, der Agent wählt automatisch:
|
||||
|
||||
1. **Agent läuft als root** (z.B. systemd `User=root`) → volle Rechte, kein sudo nötig.
|
||||
2. `SUDO_PASSWORD=…` in der `.env` → `sudo -S` mit Passwort.
|
||||
3. `SUDO_NOPASSWD=true` → `sudo -n` (Live-ISO / passwortloses sudo, z.B. Linux
|
||||
Mint vom Stick).
|
||||
4. sonst → sudo-Kommandos scheitern mit klarer Meldung.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Reagiert **nur** auf den eigenen RVS-Raum (Token) und **nur**, wenn
|
||||
`CONTROL_ENABLED=true`.
|
||||
- Keine offenen Ports (reiner ausgehender Client).
|
||||
- Alle Kommandos werden geloggt.
|
||||
- Der Agent gibt **vollen** Zugriff auf den Rechner — nur auf Maschinen
|
||||
einsetzen, denen du ARIA anvertraust.
|
||||
|
||||
## Hinweis Screenshot
|
||||
|
||||
Als Systemdienst fehlt die grafische Session. Für `screenshot` den Agent in der
|
||||
Desktop-Session starten (Autostart) oder `DISPLAY`/`XAUTHORITY` in der Unit setzen.
|
||||
@@ -0,0 +1,30 @@
|
||||
# systemd-Unit fuer den ARIA Host-Agent.
|
||||
#
|
||||
# Installation:
|
||||
# sudo cp aria-host-agent /usr/local/bin/
|
||||
# sudo mkdir -p /etc/aria-host-agent && sudo cp .env /etc/aria-host-agent/.env
|
||||
# sudo cp aria-host-agent.service /etc/systemd/system/
|
||||
# sudo systemctl enable --now aria-host-agent
|
||||
#
|
||||
# Als root (User=root): Kommandos haben volle Rechte, kein sudo/Passwort noetig.
|
||||
# Fuer einen normalen User: User=<name> setzen und in der .env SUDO_PASSWORD
|
||||
# oder SUDO_NOPASSWD konfigurieren.
|
||||
#
|
||||
# HINWEIS Screenshot: als Systemdienst fehlt die grafische Session (DISPLAY/
|
||||
# WAYLAND_DISPLAY). Fuer host_screenshot den Agent stattdessen in der Desktop-
|
||||
# Session starten (Autostart) oder DISPLAY/XAUTHORITY in der Unit setzen.
|
||||
[Unit]
|
||||
Description=ARIA Host-Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/etc/aria-host-agent
|
||||
ExecStart=/usr/local/bin/aria-host-agent
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut die portable Host-Agent-Binary (Linux x86_64) via Docker + PyInstaller.
|
||||
# Ergebnis: ./dist/aria-host-agent (Onefile, ~15 MB, keine Runtime noetig).
|
||||
#
|
||||
# ./build.sh
|
||||
#
|
||||
# Danach auf den Ziel-Rechner kopieren, .env danebenlegen und starten:
|
||||
# ./aria-host-agent
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
mkdir -p dist
|
||||
docker build -f Dockerfile.build -t aria-host-agent-build .
|
||||
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-build
|
||||
|
||||
echo
|
||||
echo "Fertig: dist/aria-host-agent"
|
||||
echo "Auf den Ziel-Rechner kopieren, .env danebenlegen (siehe .env.example), dann:"
|
||||
echo " chmod +x aria-host-agent && ./aria-host-agent"
|
||||
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
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 time
|
||||
from pathlib import Path
|
||||
|
||||
import websockets
|
||||
|
||||
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__))
|
||||
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
|
||||
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)
|
||||
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||
|
||||
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))
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
||||
|
||||
|
||||
# ─── 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):
|
||||
"""Gibt (argv, stdin_data) oder (None, fehlertext) wenn sudo nicht moeglich."""
|
||||
if not use_sudo or os.geteuid() == 0:
|
||||
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(),
|
||||
"os": platform.platform(), "kernel": platform.release(),
|
||||
"arch": platform.machine(), "python": platform.python_version(),
|
||||
"user": os.environ.get("USER") or "", "is_root": os.geteuid() == 0,
|
||||
}
|
||||
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 via System-Tool (Wayland: grim; X11: scrot/maim/import/
|
||||
gnome-screenshot). Braucht eine aktive grafische Session (DISPLAY/WAYLAND)."""
|
||||
import tempfile
|
||||
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
|
||||
wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
|
||||
candidates = []
|
||||
if wayland 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. Installiere grim (Wayland) oder "
|
||||
"scrot/maim (X11)."}
|
||||
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?"}
|
||||
|
||||
|
||||
ACTIONS = {
|
||||
"exec": _do_exec, "read": _do_read, "write": _do_write,
|
||||
"info": _do_info, "screenshot": _do_screenshot,
|
||||
}
|
||||
|
||||
|
||||
# ─── 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(),
|
||||
"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
|
||||
while True:
|
||||
proto = "wss" if RVS_TLS else "ws"
|
||||
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
try:
|
||||
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
|
||||
async with websockets.connect(url, max_size=16 * 1024 * 1024,
|
||||
ping_interval=20, ping_timeout=20) as ws:
|
||||
self.ws = ws
|
||||
backoff = 1
|
||||
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)
|
||||
finally:
|
||||
self.ws = None
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
|
||||
HOST_ID, HOST_NAME, CONTROL_ENABLED, os.geteuid() == 0)
|
||||
try:
|
||||
asyncio.run(HostAgent().run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
websockets>=12.0
|
||||
psutil>=5.9 # System-Info (CPU/RAM/Disk/Uptime) fuer host_info
|
||||
Reference in New Issue
Block a user