Files
gott/Endpoints/RemoteEndpoint.cs
T
MelekiandClaude Opus 4.6 53cee67bef feat: Remote-Execute für Linux/Bash – OS-basierte Tool-Descriptions und remote.sh Script
Remote-Verbindungen erkennen jetzt das OS des Clients und passen die Tool-Description
dynamisch an (PowerShell für Windows, Bash für Linux). Neues remote.sh Script für
Linux/macOS-Clients mit embedded Python WebSocket-Client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 09:31:56 +02:00

265 lines
9.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Gott.Endpoints;
public static class RemoteEndpoint
{
public static void MapRemoteEndpoints(this IEndpointRouteBuilder app)
{
// WebSocket-Endpoint für Remote-Clients (kein Auth)
app.Map("/api/remote", HandleWebSocket);
// REST-APIs für Frontend (Auth required)
app.MapGet("/api/remote/connections", GetConnections);
app.MapPost("/api/remote/assign", AssignConnection);
app.MapPost("/api/remote/unassign", UnassignConnection);
// 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)
{
if (!ctx.WebSockets.IsWebSocketRequest)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("WebSocket expected");
return;
}
// App-Shutdown-Token verwenden damit WebSocket-Loops sauber beendet werden
var appLifetime = ctx.RequestServices.GetRequiredService<IHostApplicationLifetime>();
var ct = appLifetime.ApplicationStopping;
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var conn = mgr.Register(ws);
using var heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
var receiveTask = ReceiveLoop(ws, conn, mgr, heartbeatCts.Token);
var heartbeatTask = HeartbeatLoop(ws, conn, heartbeatCts);
// Warten bis einer von beiden endet (Receive-Fehler oder Heartbeat-Timeout)
await Task.WhenAny(receiveTask, heartbeatTask);
heartbeatCts.Cancel();
// Beide Tasks sauber abwarten
try { await receiveTask; } catch { }
try { await heartbeatTask; } catch { }
}
catch (WebSocketException)
{
// Client disconnected
}
catch (OperationCanceledException)
{
// Shutdown oder Ctrl+C
}
finally
{
mgr.Unregister(conn.Id);
Console.WriteLine($"[Remote] Client disconnected: {conn.Id} ({conn.Hostname})");
if (ws.State == WebSocketState.Open || ws.State == WebSocketState.CloseReceived)
{
try
{
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", closeCts.Token);
}
catch { }
}
}
}
private static async Task ReceiveLoop(WebSocket ws, RemoteConnection conn, RemoteConnectionManager mgr, CancellationToken ct)
{
var buffer = new byte[64 * 1024]; // 64 KB Buffer
var messageBuffer = new MemoryStream();
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
WebSocketReceiveResult result;
messageBuffer.SetLength(0);
do
{
result = await ws.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close) return;
messageBuffer.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
if (result.MessageType != WebSocketMessageType.Text) continue;
var json = Encoding.UTF8.GetString(messageBuffer.GetBuffer(), 0, (int)messageBuffer.Length);
JsonElement msg;
try { msg = JsonSerializer.Deserialize<JsonElement>(json); }
catch { continue; }
conn.LastActivityAt = DateTime.UtcNow;
var type = msg.TryGetProperty("type", out var tp) ? tp.GetString() : "";
switch (type)
{
case "hello":
conn.Hostname = msg.TryGetProperty("hostname", out var h) ? h.GetString() ?? "" : "";
conn.Username = msg.TryGetProperty("username", out var u) ? u.GetString() ?? "" : "";
conn.OsVersion = msg.TryGetProperty("os", out var o) ? o.GetString() ?? "" : "";
Console.WriteLine($"[Remote] Client connected: {conn.Username}@{conn.Hostname} ({conn.OsVersion})");
break;
case "result":
var cmdResult = new RemoteCommandResult
{
ExitCode = msg.TryGetProperty("exitCode", out var ec) ? ec.GetInt32() : -1,
Output = msg.TryGetProperty("output", out var ot) ? ot.GetString() ?? "" : "",
Error = msg.TryGetProperty("error", out var er) ? er.GetString() ?? "" : ""
};
mgr.HandleResult(conn.Id, cmdResult);
break;
case "ping":
var pong = Encoding.UTF8.GetBytes("{\"type\":\"pong\"}");
await ws.SendAsync(pong, WebSocketMessageType.Text, true, ct);
break;
case "pong":
// Antwort auf Server-Ping → LastActivityAt ist schon gesetzt
break;
}
}
}
private static async Task HeartbeatLoop(WebSocket ws, RemoteConnection conn, CancellationTokenSource heartbeatCts)
{
const int pingIntervalSeconds = 30;
const int deadTimeoutSeconds = 90;
var pingBytes = Encoding.UTF8.GetBytes("{\"type\":\"ping\"}");
try
{
while (!heartbeatCts.IsCancellationRequested && ws.State == WebSocketState.Open)
{
await Task.Delay(TimeSpan.FromSeconds(pingIntervalSeconds), heartbeatCts.Token);
// Prüfe ob der Client noch lebt
var silenceSec = (DateTime.UtcNow - conn.LastActivityAt).TotalSeconds;
if (silenceSec > deadTimeoutSeconds)
{
Console.WriteLine($"[Remote] Heartbeat timeout ({silenceSec:F0}s): {conn.Id} ({conn.Hostname})");
heartbeatCts.Cancel();
return;
}
// Ping senden
try
{
await ws.SendAsync(pingBytes, WebSocketMessageType.Text, true, heartbeatCts.Token);
}
catch
{
// Send fehlgeschlagen → Verbindung tot
heartbeatCts.Cancel();
return;
}
}
}
catch (OperationCanceledException)
{
// Normal – ReceiveLoop hat beendet
}
}
private static IResult GetConnections(RemoteConnectionManager mgr)
{
var connections = mgr.GetAll().Select(c => new
{
id = c.Id,
hostname = c.Hostname,
username = c.Username,
os = c.OsVersion,
connectedAt = c.ConnectedAt.ToString("o"),
assignedSessionId = c.AssignedSessionId
});
return Results.Ok(connections);
}
private static IResult AssignConnection(HttpContext ctx, RemoteConnectionManager mgr)
{
using var reader = new StreamReader(ctx.Request.Body);
var body = reader.ReadToEndAsync().Result;
var json = JsonSerializer.Deserialize<JsonElement>(body);
var connectionId = json.TryGetProperty("connectionId", out var cid) ? cid.GetString() : "";
var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() : "";
if (string.IsNullOrEmpty(connectionId) || string.IsNullOrEmpty(sessionId))
return Results.BadRequest(new { error = "connectionId und sessionId erforderlich" });
if (!mgr.Assign(connectionId, sessionId))
return Results.NotFound(new { error = "Verbindung nicht gefunden" });
return Results.Ok(new { ok = true });
}
private static IResult UnassignConnection(HttpContext ctx, RemoteConnectionManager mgr)
{
using var reader = new StreamReader(ctx.Request.Body);
var body = reader.ReadToEndAsync().Result;
var json = JsonSerializer.Deserialize<JsonElement>(body);
var connectionId = json.TryGetProperty("connectionId", out var cid) ? cid.GetString() : "";
if (string.IsNullOrEmpty(connectionId))
return Results.BadRequest(new { error = "connectionId erforderlich" });
if (!mgr.Unassign(connectionId))
return Results.NotFound(new { error = "Verbindung nicht gefunden" });
return Results.Ok(new { ok = true });
}
private static async Task<IResult> ServeScript(HttpContext ctx, string filename)
{
var scriptPath = Path.Combine(AppPaths.WwwRoot, filename);
if (!File.Exists(scriptPath))
return Results.NotFound("Script nicht gefunden");
// Host aus X-Forwarded-Host (Reverse-Proxy) oder Request.Host
var host = ctx.Request.Headers["X-Forwarded-Host"].FirstOrDefault()
?? ctx.Request.Host.ToString();
// Schema aus X-Forwarded-Proto oder Request.Scheme
var httpScheme = ctx.Request.Headers["X-Forwarded-Proto"].FirstOrDefault()
?? ctx.Request.Scheme;
var wsScheme = httpScheme == "https" ? "wss" : "ws";
var wsUrl = $"{wsScheme}://{host}/api/remote";
var script = await File.ReadAllTextAsync(scriptPath);
script = script.Replace("{{WS_URL}}", wsUrl);
return Results.Content(script, "text/plain; charset=utf-8");
}
}