using ClawdDotNet.Core.Logging;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager.Web;
///
/// Die eine Weboberfläche für alle Instanzen (#528, E-M5).
///
/// Eine Adresse, ein Zugang, alle Instanzen. Der Manager besitzt Port, Zugang
/// und Rahmen; Anfragen zu Chat, Agenten, Aufgaben und Freigaben reicht er über den Socket
/// an die betroffene Instanz weiter. Vorher hatte jede Instanz ihren eigenen Port, und
/// zwei Instanzen stritten sich stillschweigend um die Vorbelegung 7001.
///
/// Er leitet weiter, er implementiert nicht. Unter /api/verwaltung
/// steht, was den Manager selbst angeht: welche Instanzen es gibt, anlegen, starten,
/// anhalten. Alles andere unter /api geht unverändert an eine Instanz. Wer hier
/// einen Endpunkt ergänzt, der etwas über Agenten oder Aufgaben weiß, hat Fachlogik in
/// den Manager gelegt.
///
/// Zwei Dinge, die er nicht kann und nicht können soll: Er fasst keine
/// state.db an — die gehört genau einem Prozess, und prozessübergreifend gilt die
/// Schreibsperre von SqliteStorage nicht. Und er führt keine Werkzeuge aus;
/// Telegram, Rocket.Chat, Mail und FTP laufen ausschließlich in den Instanzen.
///
public sealed class ManagerWebHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly ILogger _logger;
private readonly InstanceForwarding _forwarding;
private ManagerWebHost(
WebApplication app, ILogger logger, InstanceForwarding forwarding, string url)
{
_app = app;
_logger = logger;
_forwarding = forwarding;
Url = url;
}
/// Die Adresse, unter der die Oberfläche hört.
public string Url { get; }
///
/// Fährt die Weboberfläche hoch.
///
/// Gibt in jedem Fehlerfall null zurück und wirft nicht. Ein
/// Manager, der nicht anläuft, weil ein Port belegt ist, wäre genau dann im Weg, wenn
/// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
/// steht im Protokoll.
///
public static ManagerWebHost? TryStart(ManagerCore manager, ManagerBackups backups, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
var settings = manager.Settings;
var token = settings.WebUiToken?.Trim() ?? "";
if (token.Length < AccessToken.MinimumLength)
{
// Keine halben Sachen: Ein Netzwerkdienst, der Agenten mit Datei-, Mail- und
// FTP-Zugriff steuert, ist nichts, was mit einem schwachen oder gar keinem
// Token offen stehen darf.
logger.LogWarning(
"Weboberfläche nicht gestartet: Es fehlt ein Zugangstoken von mindestens "
+ "{Mindestlänge} Zeichen (webUiToken in manager.json).",
AccessToken.MinimumLength);
return null;
}
var loopback = !string.Equals(settings.WebUiBinding, "any", StringComparison.OrdinalIgnoreCase);
var host = loopback ? "127.0.0.1" : "0.0.0.0";
var url = $"http://{host}:{settings.WebUiPort}";
var forwarding = new InstanceForwarding(logger);
try
{
var app = Build(manager, backups, loggerFactory, url, token, forwarding);
app.StartAsync().GetAwaiter().GetResult();
var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName);
logger.LogInformation(
"Weboberfläche hört auf {Url} ({Bindung})",
anzeige, loopback ? "nur lokal" : "alle Adressen");
return new ManagerWebHost(app, logger, forwarding, anzeige);
}
catch (Exception ex)
{
forwarding.Dispose();
logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", url);
return null;
}
}
private static WebApplication Build(
ManagerCore manager,
ManagerBackups backups,
ILoggerFactory loggerFactory,
string url,
string token,
InstanceForwarding forwarding)
{
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
ContentRootPath = AppContext.BaseDirectory,
EnvironmentName = Environments.Production,
ApplicationName = typeof(ManagerWebHost).Assembly.GetName().Name
});
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
// ASP.NET protokolliert auf Information jede einzelne Anfrage - auch jede
// Schriftart und jedes Stylesheet. Ab Warnung ist es das, was man wissen will.
builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning);
builder.WebHost.UseUrls(url);
builder.Services.AddSingleton(manager);
builder.Services.AddSingleton(manager.Instances);
builder.Services.AddSingleton(backups);
builder.Services.AddSingleton(AccessToken.For(token));
builder.Services.AddSingleton(forwarding);
var app = builder.Build();
app.Use(ReportUnhandled(loggerFactory));
app.Use(CheckToken);
MapStaticFiles(app);
app.MapVerwaltung();
app.MapEvents();
app.MapForwarding();
return app;
}
private static Func ReportUnhandled(
ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
return async (context, next) =>
{
try
{
await next(context);
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt, oder der Manager faehrt herunter. Bewusst
// ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht
// ApplicationStopping ab, und RequestAborted ist dabei nicht gesetzt.
}
catch (Exception ex)
{
logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path);
if (context.Response.HasStarted) return;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { fehler = "Interner Fehler." });
}
};
}
private static async Task CheckToken(HttpContext context, RequestDelegate next)
{
if (!AccessToken.NeedsToken(context.Request.Path))
{
await next(context);
return;
}
var gate = context.RequestServices.GetRequiredService();
if (!gate.Matches(AccessToken.Read(context.Request)))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." });
return;
}
await next(context);
}
private static void MapStaticFiles(WebApplication app)
{
var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(webRoot))
{
app.Logger.LogWarning(
"Kein wwwroot unter {Pfad} — es läuft nur die API.", webRoot);
return;
}
var files = new PhysicalFileProvider(webRoot);
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
}
public async ValueTask DisposeAsync()
{
try
{
await _app.StopAsync(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden");
}
_forwarding.Dispose();
await _app.DisposeAsync();
}
}