using ClawdDotNet.Core.Instances; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Manager.Web; /// /// Reicht eine Anfrage an den Socket der gemeinten Instanz durch. /// /// Er leitet weiter, er implementiert nicht (E-M5). Diese Klasse kennt /// Kennungen und Pfade, sonst nichts — keinen Agenten, keine Aufgabe, kein Taskboard. Wer /// hier anfängt, eine Antwort umzuformen oder eine Regel anzuwenden, hat Fachlogik in den /// Manager gelegt, und die gehört in die Instanz. /// /// Deshalb bleibt das Protokoll HTTP (E-M1). Weil auf beiden Seiten HTTP /// steht, ist Weiterleiten eine Weiterleitung und keine Übersetzung: Die Endpunkte, die /// Übertragungstypen und der Ereignisstrom aus #494 gelten unverändert weiter, es ändert /// sich nur die Bindung. /// internal sealed class InstanceForwarding(ILogger logger) : IDisposable { /// /// Je Instanz ein Client, und der bleibt. /// /// Ein HttpClient je Anfrage wäre hier besonders teuer: Jeder brächte /// einen eigenen Verbindungsaufbau zum Socket mit, und die Weboberfläche fragt viel /// und oft. /// private readonly Dictionary _clients = new(ClawdDotNet.Core.Storage.PathBoundary.Comparer); private readonly Lock _lock = new(); /// /// Kopfzeilen, die nicht weitergereicht werden. /// /// Authorization, weil der Socket kein Token kennt und keines bekommen /// soll — die Grenze steht dort am Dateisystem. Host, weil die Gegenstelle /// keinen Hostnamen hat. Die Übertragungskopfzeilen, weil sie die Verbindung /// beschreiben und nicht den Inhalt: Sie einfach mitzunehmen ergibt eine Antwort, die /// doppelt gestückelt ist. /// private static readonly HashSet NotForwarded = new(StringComparer.OrdinalIgnoreCase) { "Authorization", "Host", "Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Authorization", "TE", "Trailer", InstanceRouting.HeaderName }; public async Task ForwardAsync( HttpContext context, ManagedInstance ziel, string pfad, CancellationToken ct) { var client = ClientFor(ziel.Directory); using var anfrage = new HttpRequestMessage( new HttpMethod(context.Request.Method), pfad + context.Request.QueryString); if (context.Request.ContentLength is > 0 || context.Request.Headers.ContentLength is null) { // Den Rumpf stroemen lassen statt ihn zu puffern: Eine hochgeladene Datei // muss nicht erst vollstaendig im Manager liegen, bevor die Instanz sie sieht. anfrage.Content = new StreamContent(context.Request.Body); foreach (var (name, werte) in context.Request.Headers) { if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase)) anfrage.Content.Headers.TryAddWithoutValidation(name, werte.ToArray()); } } foreach (var (name, werte) in context.Request.Headers) { if (NotForwarded.Contains(name)) continue; if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase)) continue; anfrage.Headers.TryAddWithoutValidation(name, werte.ToArray()); } HttpResponseMessage antwort; try { antwort = await client.SendAsync( anfrage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { // Die Instanz antwortet nicht. 502 und nicht 500: Der Fehler liegt nicht hier, // und das gehoert in die Antwort - sonst sucht jemand im Manager nach einem // Problem, das in der Instanz steckt oder darin, dass sie gar nicht laeuft. logger.LogWarning(ex, "Instanz {Name} war für {Methode} {Pfad} nicht erreichbar.", ziel.Name, context.Request.Method, pfad); context.Response.StatusCode = StatusCodes.Status502BadGateway; await context.Response.WriteAsJsonAsync( new { fehler = $"Die Instanz '{ziel.Name}' antwortet nicht." }, ct); return; } using (antwort) { context.Response.StatusCode = (int)antwort.StatusCode; foreach (var (name, werte) in antwort.Headers) { if (!NotForwarded.Contains(name)) context.Response.Headers[name] = werte.ToArray(); } foreach (var (name, werte) in antwort.Content.Headers) { if (!NotForwarded.Contains(name)) context.Response.Headers[name] = werte.ToArray(); } // Kestrel bestimmt selbst, wie er stueckelt. context.Response.Headers.Remove("transfer-encoding"); await antwort.Content.CopyToAsync(context.Response.Body, ct).ConfigureAwait(false); } } /// /// #713: An MCP message for an instance — to its MCP socket, not its instance socket. /// /// This is the one forwarding that keeps Authorization: the MCP token is /// what the instance checks, and the manager adds nothing of its own. Through the /// instance socket the same request would have arrived token-free and with all rights — /// that is why it never goes there. /// public async Task ForwardMcpAsync(HttpContext context, ManagedInstance ziel, CancellationToken ct) { HttpClient client; lock (_lock) { var key = "mcp|" + ziel.Directory; if (!_clients.TryGetValue(key, out client!)) _clients[key] = client = InstanceEndpoint.CreateMcpClient(ziel.Directory); } using var anfrage = new HttpRequestMessage(HttpMethod.Post, "/mcp") { Content = new StreamContent(context.Request.Body) }; anfrage.Content.Headers.TryAddWithoutValidation("Content-Type", "application/json"); if (context.Request.Headers.Authorization.Count > 0) anfrage.Headers.TryAddWithoutValidation("Authorization", context.Request.Headers.Authorization.ToString()); try { using var antwort = await client.SendAsync(anfrage, ct).ConfigureAwait(false); context.Response.StatusCode = (int)antwort.StatusCode; if (antwort.Content.Headers.ContentType is { } typ) context.Response.ContentType = typ.ToString(); await antwort.Content.CopyToAsync(context.Response.Body, ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "Instanz {Name} war für MCP nicht erreichbar.", ziel.Name); context.Response.StatusCode = StatusCodes.Status502BadGateway; } } private HttpClient ClientFor(string instanceDirectory) { lock (_lock) { if (_clients.TryGetValue(instanceDirectory, out var vorhanden)) return vorhanden; var client = InstanceEndpoint.CreateClient(instanceDirectory); // Grosszuegig: Hinter einer weitergeleiteten Anfrage kann ein Agentenlauf // stecken, und der dauert, so lange er dauert. Der Ereignisstrom laeuft // ohnehin nicht hierueber. client.Timeout = TimeSpan.FromMinutes(10); _clients[instanceDirectory] = client; return client; } } public void Dispose() { lock (_lock) { foreach (var client in _clients.Values) client.Dispose(); _clients.Clear(); } } }