diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 9374e2f..ecdc5a4 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -53,8 +53,9 @@ public static class ChatEndpoint var maxIterations = settings.GetInt("MaxIterations", 100); - var hasRemote = remoteMgr.GetBySessionId(sessionId) != null; - var ctx = await chatService.PrepareContext(sessionId, userId, maxIterations, planMode: body.PlanMode, hasRemoteConnection: hasRemote); + var remoteConn = remoteMgr.GetBySessionId(sessionId); + var hasRemote = remoteConn != null; + var ctx = await chatService.PrepareContext(sessionId, userId, maxIterations, planMode: body.PlanMode, hasRemoteConnection: hasRemote, remoteOs: remoteConn?.OsVersion); if (ctx == null) { res.StatusCode = 400; diff --git a/Endpoints/RemoteEndpoint.cs b/Endpoints/RemoteEndpoint.cs index 992ceee..25ab222 100644 --- a/Endpoints/RemoteEndpoint.cs +++ b/Endpoints/RemoteEndpoint.cs @@ -29,9 +29,9 @@ public static class RemoteEndpoint app.MapPost("/api/remote/assign", AssignConnection); app.MapPost("/api/remote/unassign", UnassignConnection); - // PowerShell-Script ausliefern - app.MapGet("/remote.ps1", (Delegate)ServeScript); - app.MapGet("/remote", (Delegate)ServeScript); + // Scripts ausliefern + app.MapGet("/remote.ps1", (Delegate)((HttpContext ctx) => ServeScript(ctx, "remote.ps1"))); + app.MapGet("/remote.sh", (Delegate)((HttpContext ctx) => ServeScript(ctx, "remote.sh"))); } private static async Task HandleWebSocket(HttpContext ctx, RemoteConnectionManager mgr) @@ -239,9 +239,9 @@ public static class RemoteEndpoint return Results.Ok(new { ok = true }); } - private static async Task ServeScript(HttpContext ctx) + private static async Task ServeScript(HttpContext ctx, string filename) { - var scriptPath = Path.Combine(AppPaths.WwwRoot, "remote.ps1"); + var scriptPath = Path.Combine(AppPaths.WwwRoot, filename); if (!File.Exists(scriptPath)) return Results.NotFound("Script nicht gefunden"); diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs index 4a17702..6bb50a7 100644 --- a/Middleware/AuthMiddleware.cs +++ b/Middleware/AuthMiddleware.cs @@ -25,7 +25,7 @@ public class AuthMiddleware var isAutologin = path == "/api/auth/autologin" && ctx.Request.Method == "GET"; var isImageFile = path.StartsWith("/api/images/file/") && ctx.Request.Method == "GET"; var isRemoteWs = path == "/api/remote"; - var isRemoteScript = path == "/remote.ps1"; + var isRemoteScript = path == "/remote.ps1" || path == "/remote.sh"; var isInternal = path.StartsWith("/api/internal/"); if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet || isAutologin || isImageFile || isRemoteWs || isRemoteScript || isInternal) { diff --git a/Services/AnthropicService.Cli.cs b/Services/AnthropicService.Cli.cs index 281a3c3..606cc21 100644 --- a/Services/AnthropicService.Cli.cs +++ b/Services/AnthropicService.Cli.cs @@ -64,8 +64,10 @@ public partial class AnthropicService try { // MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute) - var hasRemote = remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null; - mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote, unityMcpUrl, unityMcpToken); + var remoteConn = remoteMgr != null && !string.IsNullOrEmpty(sessionId) ? remoteMgr.GetBySessionId(sessionId) : null; + var hasRemote = remoteConn != null; + var remoteOs = remoteConn?.OsVersion ?? ""; + mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote, unityMcpUrl, unityMcpToken, remoteOs); // Python-Verfügbarkeit prüfen und ggf. warnen if (FindPythonCommand() == null) @@ -567,7 +569,7 @@ public partial class AnthropicService /// Generiert eine temporäre MCP-Config-Datei für die Claude CLI. /// Enthält den MCP-Server für remote_execute. /// - private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false, string unityMcpUrl = null, string unityMcpToken = null) + private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false, string unityMcpUrl = null, string unityMcpToken = null, string remoteOs = null) { // Port aus Konfiguration oder Default var port = 19000; @@ -587,7 +589,8 @@ public partial class AnthropicService ["GOTT_SESSION_ID"] = sessionId ?? "", ["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken, ["GOTT_AGENT_ID"] = agentId ?? "", - ["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false" + ["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false", + ["GOTT_REMOTE_OS"] = remoteOs ?? "" }; var pythonCmd = FindPythonCommand(); diff --git a/Services/AnthropicService.Streaming.cs b/Services/AnthropicService.Streaming.cs index 3857175..0ae42ec 100644 --- a/Services/AnthropicService.Streaming.cs +++ b/Services/AnthropicService.Streaming.cs @@ -101,7 +101,8 @@ public partial class AnthropicService if (systemPrompt != null && systemPrompt.Count > 0) payload["system"] = systemPrompt; - var hasRemote = remoteMgr?.GetBySessionId(sessionId) != null; + var remoteConn = remoteMgr?.GetBySessionId(sessionId); + var hasRemote = remoteConn != null; AddToolsToPayload(payload, new ToolPayloadOptions { Workspace = workspace, @@ -113,6 +114,7 @@ public partial class AnthropicService AllowUserChoice = allowUserChoice, PlanMode = planMode, IncludeRemote = hasRemote, + RemoteOs = remoteConn?.OsVersion, IncludeGitHubSearch = useGitHubSearch && _toolService.HasGitHubToken, IncludeSerperSearch = useSerperSearch && _toolService.HasSerperKey }); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 45578ce..32e0ab7 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -31,6 +31,7 @@ public partial class AnthropicService public bool AllowUserChoice { get; init; } public bool PlanMode { get; init; } public bool IncludeRemote { get; init; } + public string RemoteOs { get; init; } public bool IncludeGitHubSearch { get; init; } public bool IncludeSerperSearch { get; init; } } @@ -337,7 +338,7 @@ public partial class AnthropicService var allTools = _toolService.GetToolDefinitions(new ToolDefinitionOptions { IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory, - IncludeUserChoice = opts.AllowUserChoice, IncludeRemote = opts.IncludeRemote, + IncludeUserChoice = opts.AllowUserChoice, IncludeRemote = opts.IncludeRemote, RemoteOs = opts.RemoteOs, IncludeMemorySearch = includeMemorySearch, IncludeSendToSession = includeSendToSession, IncludeGitHubSearch = opts.IncludeGitHubSearch, IncludeSerperSearch = opts.IncludeSerperSearch }); diff --git a/Services/ChatService.cs b/Services/ChatService.cs index 39f5537..8d9a3ab 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -51,7 +51,7 @@ public class ChatService /// /// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc. /// - public async Task PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false, bool hasRemoteConnection = false) + public async Task PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false, bool hasRemoteConnection = false, string remoteOs = null) { var session = await _db.GetSession(sessionId); if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId)) @@ -89,7 +89,7 @@ public class ChatService ? _toolService.GetToolDefinitions(new ToolDefinitionOptions { IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory, - IncludeUserChoice = agent.AllowUserChoice, IncludeRemote = hasRemoteConnection, + IncludeUserChoice = agent.AllowUserChoice, IncludeRemote = hasRemoteConnection, RemoteOs = remoteOs, IncludeGitHubSearch = includeGitHubSearch, IncludeSerperSearch = includeSerperSearch }) : Array.Empty(); diff --git a/Services/ToolService.cs b/Services/ToolService.cs index e393d50..ca24999 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -21,6 +21,7 @@ public class ToolDefinitionOptions public bool IncludeMemory { get; set; } public bool IncludeUserChoice { get; set; } = true; public bool IncludeRemote { get; set; } + public string RemoteOs { get; set; } public bool IncludeMemorySearch { get; set; } public bool IncludeSendToSession { get; set; } public bool IncludeGitHubSearch { get; set; } @@ -106,7 +107,7 @@ public class ToolService if (opts.IncludeMemorySearch) allTools.Add(GetMemorySearchTool()); if (opts.IncludeRemote) - allTools.Add(GetRemoteExecuteTool()); + allTools.Add(GetRemoteExecuteTool(opts.RemoteOs)); if (opts.IncludeSendToSession) allTools.Add(GetSendToSessionTool()); if (opts.IncludeGitHubSearch) @@ -352,18 +353,26 @@ public class ToolService }; } - private object GetRemoteExecuteTool() + private object GetRemoteExecuteTool(string remoteOs = null) { + var isLinux = (remoteOs ?? "").Contains("Linux", StringComparison.OrdinalIgnoreCase); + var desc = isLinux + ? "Führt einen Bash-Befehl auf der verbundenen Remote-Linux-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Netzwerk, Services, Logs, Pakete). Timeout: 180 Sekunden." + : "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden."; + var cmdDesc = isLinux + ? "Bash-Befehl zum Ausführen auf dem Remote-PC" + : "PowerShell-Befehl zum Ausführen auf dem Remote-PC"; + return new { name = "remote_execute", - description = "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden.", + description = desc, input_schema = new { type = "object", properties = new Dictionary { - ["command"] = new { type = "string", description = "PowerShell-Befehl zum Ausführen auf dem Remote-PC" } + ["command"] = new { type = "string", description = cmdDesc } }, required = new[] { "command" } } diff --git a/scripts/mcp-remote-execute.py b/scripts/mcp-remote-execute.py index 9b74b5d..bafcca6 100644 --- a/scripts/mcp-remote-execute.py +++ b/scripts/mcp-remote-execute.py @@ -21,22 +21,34 @@ SESSION_ID = os.environ.get("GOTT_SESSION_ID", "") AUTH_TOKEN = os.environ.get("GOTT_AUTH_TOKEN", "") AGENT_ID = os.environ.get("GOTT_AGENT_ID", "") HAS_REMOTE = os.environ.get("GOTT_HAS_REMOTE", "false") == "true" +REMOTE_OS = os.environ.get("GOTT_REMOTE_OS", "").lower() # z.B. "windows", "linux" TOOLS = [] # Remote-Execute Tool (nur wenn Remote-Verbindung vorhanden) if HAS_REMOTE: - TOOLS.append({ - "name": "remote_execute", - "description": ( + if "linux" in REMOTE_OS: + _desc = ( + "Führt einen Bash-Befehl auf der verbundenen Remote-Linux-Workstation aus. " + "Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs " + "(z.B. Netzwerk, Services, Logs, Pakete). Timeout: 180 Sekunden." + ) + _cmd_desc = "Bash-Befehl zum Ausführen auf dem Remote-PC" + else: + _desc = ( "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. " "Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs " "(z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden." - ), + ) + _cmd_desc = "PowerShell-Befehl zum Ausführen auf dem Remote-PC" + + TOOLS.append({ + "name": "remote_execute", + "description": _desc, "inputSchema": { "type": "object", "properties": { - "command": {"type": "string", "description": "PowerShell-Befehl zum Ausführen auf dem Remote-PC"} + "command": {"type": "string", "description": _cmd_desc} }, "required": ["command"] } diff --git a/wwwroot/js/modals.js b/wwwroot/js/modals.js index 49c4e75..0bd74d0 100644 --- a/wwwroot/js/modals.js +++ b/wwwroot/js/modals.js @@ -324,8 +324,10 @@ async function showRemoteModal(sessionId) { var empty = document.createElement('div'); empty.className = 'sp-loading'; - empty.innerHTML = '
Powershell-Befehl unter Windows ausführen
' + - 'irm ' + esc(location.origin) + '/remote | iex'; + empty.innerHTML = '
Windows (PowerShell)
' + + 'irm ' + esc(location.origin) + '/remote.ps1 | iex' + + '
Linux (Bash)
' + + 'curl -s ' + esc(location.origin) + '/remote.sh | bash'; body.appendChild(empty); if (!connections || connections.length === 0) { diff --git a/wwwroot/remote.sh b/wwwroot/remote.sh new file mode 100644 index 0000000..8341f4e --- /dev/null +++ b/wwwroot/remote.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +# GOTT Remote Agent - Linux Bash Client +# Verbindet sich per WebSocket zum GOTT-Server und fuehrt Befehle aus. +# +# Verwendung: +# curl -s https://gott.example.com/remote.sh | bash +# oder: +# chmod +x remote.sh && ./remote.sh +# oder mit eigener URL: +# WS_URL="wss://example.com/api/remote" ./remote.sh + +WS_URL="${WS_URL:-{{WS_URL}}}" + +# Python finden +PYTHON="" +for cmd in python3 python py; do + if command -v "$cmd" &>/dev/null; then + PYTHON="$cmd" + break + fi +done + +if [ -z "$PYTHON" ]; then + echo "" + echo " [FEHLER] Python nicht gefunden!" + echo " Bitte installiere Python 3: sudo apt install python3" + echo "" + exit 1 +fi + +# Python-Script inline ausfuehren +exec "$PYTHON" - "$WS_URL" << 'PYTHON_SCRIPT' +import sys +import os +import json +import subprocess +import signal +import socket +import ssl +import hashlib +import base64 +import struct +import time +from urllib.parse import urlparse + +WS_URL = sys.argv[1] if len(sys.argv) > 1 else "ws://localhost/api/remote" + +CYAN = "\033[96m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +RED = "\033[91m" +GRAY = "\033[90m" +WHITE = "\033[97m" +RESET = "\033[0m" + +def cprint(msg, color=""): + print(f"{color}{msg}{RESET}", flush=True) + +class WebSocketClient: + """Minimaler WebSocket-Client (RFC 6455) ohne externe Abhaengigkeiten.""" + + def __init__(self, url): + self.url = url + self.sock = None + self.closed = False + + def connect(self): + parsed = urlparse(self.url) + use_ssl = parsed.scheme in ("wss", "https") + host = parsed.hostname + port = parsed.port or (443 if use_ssl else 80) + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + + self.sock = socket.create_connection((host, port), timeout=10) + if use_ssl: + ctx = ssl.create_default_context() + self.sock = ctx.wrap_socket(self.sock, server_hostname=host) + + # WebSocket-Handshake + key = base64.b64encode(os.urandom(16)).decode() + handshake = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}\r\n" + f"Upgrade: websocket\r\n" + f"Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + f"Sec-WebSocket-Version: 13\r\n" + f"\r\n" + ) + self.sock.sendall(handshake.encode()) + + # Response lesen + response = b"" + while b"\r\n\r\n" not in response: + chunk = self.sock.recv(4096) + if not chunk: + raise ConnectionError("Handshake fehlgeschlagen") + response += chunk + + status_line = response.split(b"\r\n")[0].decode() + if "101" not in status_line: + raise ConnectionError(f"WebSocket-Handshake fehlgeschlagen: {status_line}") + + self.sock.settimeout(None) + + def send(self, data): + if self.closed: + return + payload = data.encode("utf-8") + frame = bytearray() + frame.append(0x81) # FIN + Text + + length = len(payload) + if length < 126: + frame.append(0x80 | length) # Mask-Bit gesetzt + elif length < 65536: + frame.append(0x80 | 126) + frame.extend(struct.pack(">H", length)) + else: + frame.append(0x80 | 127) + frame.extend(struct.pack(">Q", length)) + + # Masking Key (Client muss maskieren) + mask = os.urandom(4) + frame.extend(mask) + masked = bytearray(b ^ mask[i % 4] for i, b in enumerate(payload)) + frame.extend(masked) + + self.sock.sendall(frame) + + def recv(self): + if self.closed: + return None + + def read_exact(n): + data = b"" + while len(data) < n: + chunk = self.sock.recv(n - len(data)) + if not chunk: + raise ConnectionError("Verbindung geschlossen") + data += chunk + return data + + header = read_exact(2) + opcode = header[0] & 0x0F + is_masked = header[1] & 0x80 + length = header[1] & 0x7F + + if opcode == 0x8: # Close + self.closed = True + return None + if opcode == 0x9: # Ping + self.send_pong() + return self.recv() + + if length == 126: + length = struct.unpack(">H", read_exact(2))[0] + elif length == 127: + length = struct.unpack(">Q", read_exact(8))[0] + + if is_masked: + mask = read_exact(4) + data = read_exact(length) + data = bytearray(b ^ mask[i % 4] for i, b in enumerate(data)) + else: + data = read_exact(length) + + return data.decode("utf-8") + + def send_pong(self): + if self.closed: + return + frame = bytearray([0x8A, 0x80]) # FIN + Pong, Mask-Bit, Length 0 + frame.extend(os.urandom(4)) # Mask key + self.sock.sendall(frame) + + def close(self): + if self.closed: + return + self.closed = True + try: + # Close-Frame senden + frame = bytearray([0x88, 0x82]) # FIN + Close, Mask-Bit, Length 2 + mask = os.urandom(4) + frame.extend(mask) + # Status 1000 (Normal Closure) maskiert + code = struct.pack(">H", 1000) + frame.extend(bytearray(b ^ mask[i % 4] for i, b in enumerate(code))) + self.sock.sendall(frame) + except Exception: + pass + try: + self.sock.close() + except Exception: + pass + + +def main(): + print() + cprint(" ============================", CYAN) + cprint(" GOTT Remote Agent", CYAN) + cprint(" ============================", CYAN) + print() + cprint(f" Verbinde zu: {WS_URL}", GRAY) + print() + + ws = WebSocketClient(WS_URL) + running = True + + def on_signal(sig, frame): + nonlocal running + running = False + + signal.signal(signal.SIGINT, on_signal) + signal.signal(signal.SIGTERM, on_signal) + + try: + ws.connect() + except Exception as e: + cprint(f" [FEHLER] Verbindung fehlgeschlagen: {e}", RED) + cprint(f" URL: {WS_URL}", GRAY) + return + + cprint(" [OK] Verbunden!", GREEN) + + # Hello-Message senden + hostname = os.uname().nodename if hasattr(os, "uname") else socket.gethostname() + username = os.environ.get("USER", os.environ.get("LOGNAME", "unknown")) + import platform + os_version = f"{platform.system()} {platform.release()}" + + hello = json.dumps({ + "type": "hello", + "hostname": hostname, + "username": username, + "os": os_version + }) + ws.send(hello) + + cprint(f" Host: {username}@{hostname} ({os_version})", GRAY) + print() + cprint(" Warte auf Befehle... (Strg+C zum Beenden)", YELLOW) + print() + + while running and not ws.closed: + try: + raw = ws.recv() + except (ConnectionError, OSError): + cprint(" Verbindung verloren.", RED) + break + + if raw is None: + cprint(" Server hat Verbindung geschlossen.", YELLOW) + break + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + + msg_type = msg.get("type", "") + + if msg_type == "execute": + command = msg.get("command", "") + timestamp = time.strftime("%H:%M:%S") + print(f" {CYAN}[{timestamp}] Ausfuehren: {WHITE}{command}{RESET}", flush=True) + + exit_code = 0 + stdout = "" + stderr = "" + + try: + result = subprocess.run( + ["bash", "-c", command], + capture_output=True, + text=True, + timeout=300 + ) + stdout = result.stdout + stderr = result.stderr + exit_code = result.returncode + except subprocess.TimeoutExpired: + stderr = "Timeout nach 300 Sekunden" + exit_code = 124 + except Exception as e: + stderr = str(e) + exit_code = 1 + + response = json.dumps({ + "type": "result", + "exitCode": exit_code, + "output": stdout or "", + "error": stderr or "" + }) + ws.send(response) + + # Kurzausgabe lokal + if stdout: + preview = stdout[:200] + "..." if len(stdout) > 200 else stdout.rstrip() + cprint(f" -> {preview}", GRAY) + if stderr: + cprint(f" [stderr] {stderr.rstrip()}", RED) + color = GREEN if exit_code == 0 else RED + cprint(f" [Exit: {exit_code}]", color) + print() + + elif msg_type == "ping": + ws.send('{"type":"pong"}') + + elif msg_type == "pong": + pass + + else: + cprint(f" Unbekannte Nachricht: {msg_type}", YELLOW) + + ws.close() + print() + cprint(" Verbindung beendet.", YELLOW) + + +main() +PYTHON_SCRIPT