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