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();
}
}
}