Files
gott/Endpoints/RemoteEndpoint.cs
MelekiandClaude Opus 4.8 ace5036f00 Remote-Keep-Alive gegen 180s-Timeout + System-Prompt via Datei
Task 1 – Remote-Befehle laufen nicht mehr in einen starren 180s-Timeout:
- RemoteConnectionManager.ExecuteCommand nutzt jetzt einen INAKTIVITÄTS-
  Timeout (Watchdog auf LastProgressAt) statt hartem Wall-Clock-Limit; ein
  Befehl darf beliebig lange laufen, solange der Client Progress-Frames sendet.
- RemoteEndpoint: neuer "progress"-Case setzt die Frist zurück.
- InternalEndpoint (MCP-Pfad remote_execute): hartes CancellationTokenSource(180s)
  entfernt → nutzt konfigurierbares ExecuteTimeout-Setting + RequestAborted.
- remote.ps1 / remote.sh: senden während laufender Befehle alle 15s ein
  {"type":"progress"}-Keep-Alive; async stdout/stderr-Lesen gegen Pipe-Deadlocks.
- ToolService: Tool-Beschreibungen erklären den Inaktivitäts-Timeout.

Task 2 – System-Prompt über --system-prompt-file statt CLI-Argument:
- ClaudeCliService schreibt den vollen System-Prompt in eine Temp-Datei und
  übergibt --system-prompt-file, um das Windows-Kommandozeilen-Längenlimit zu
  umgehen. Cleanup im finally. Agent-Verhalten unverändert (MEMORY.md bleibt inline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:21:55 +02:00

272 lines
10 KiB
C#
Raw Permalink 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 "progress":
// Keep-Alive während eines laufenden Befehls: Inaktivitäts-Timeout in
// ExecuteCommand zurücksetzen (LastActivityAt oben deckt zusätzlich den
// WebSocket-Dead-Timeout ab). So laufen lange Befehle nicht in den Timeout.
mgr.HandleProgress(conn.Id);
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");
}
}