diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx index 51678ec..6507084 100644 --- a/ClawdDotNet.slnx +++ b/ClawdDotNet.slnx @@ -22,6 +22,8 @@ + + diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md index 4ecff0e..dd0db23 100644 --- a/docs/Instanzmanager-Konzept.md +++ b/docs/Instanzmanager-Konzept.md @@ -322,7 +322,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis. | 1 ✔ | Einstellungen und Protokolle je Instanz trennen (Abschnitt 6) | Zwei Instanzen gleichzeitig, keine schreibt der anderen ins Protokoll | | 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet | | 3 ◑ | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es | -| 4 | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart | +| 4 ◑ | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart | | 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln | | 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder | @@ -549,3 +549,65 @@ Sockets hinterlegt, *bevor* der Manager hochfuhr — damit sagt man „diese Ins bereits", der Manager übernimmt sie (E-M6), der Starter wird nie angefasst, und der Test prüft etwas anderes, als er behauptet. Es gibt jetzt eine Hilfsmethode, die genau das ausschließt. + +### 9.5 Was Stufe 4 gebracht hat + +**Den Managerprozess.** `ClawdDotNet.Instanzmanager` ist eine Konsolenanwendung: Sie prüft +die Lizenz, übernimmt, was läuft, hängt die Weboberfläche auf und wartet. Sie beendet beim +Herunterfahren keine Instanz. + +**Zwei neue Projekte.** `ClawdDotNet.Manager.Web` trägt die Weboberfläche — ohne jeden +Verweis auf `ClawdDotNet.Web`, denn dort hängt `App` und daran sechzehn Werkzeugprojekte. +`ClawdDotNet.ManagerApp` ist der Einstiegspunkt. `wwwroot` ist aus der Instanz in den +Manager gezogen (Abschnitt 8). + +**Der Schnitt der HTTP-Fläche.** Unter `/api/verwaltung` steht, was den Manager angeht: +Instanzen auflisten, anlegen, starten, anhalten. **Alles andere unter `/api` geht +unverändert an eine Instanz** — er leitet weiter, er implementiert nicht. Welche Instanz +gemeint ist, sagt die Kopfzeile `X-Instanz`; läuft genau eine, ist sie gemeint, und laufen +mehrere, wird nachgefragt statt geraten. Eine Anfrage an die falsche Instanz ist schlimmer +als eine abgelehnte. + +**Der zusammengeführte Ereignisstrom.** Der Manager hält je laufender Instanz eine +Zuleitung und schreibt an jedes Ereignis, von wem es kommt (`instanz`, `instanzName`). +Ergänzt statt verpackt: Ein Umschlag wäre einfacher gewesen, hätte aber jede Ansicht der +Weboberfläche zum Umschreiben gezwungen. + +**Abnahme, gegen laufende Prozesse gemessen:** + +| Geprüft | Ergebnis | +|---|---| +| `/api/verwaltung/instanzen` ohne Token | 401 | +| Oberfläche ohne Token | 200 — die leere Hülle, wie vorgesehen | +| Instanz über den Manager starten | `Running`, PID 18104 | +| `/api/info` durchgereicht | Antwort **der Instanz** (`instanceId d68af6fd`) | +| `/api/status` durchgereicht | 503 der Instanz, unverfälscht | +| `/events/logs` | Zeilen der Instanz, mit `"instanz":"Instance-Test"` daran | +| Anhalten über den Manager | `Stopped` | +| **Manager abgeschossen** | Instanz läuft weiter (E-M6) | +| **Manager neu gestartet** | „1 Instanzen bekannt, 1 laufen bereits" — übernommen | + +**Drei Defekte, die erst der Lauf gezeigt hat:** + +1. **Der Manager hing an einer Konsolenabfrage.** `ConsoleLicensePrompt` wartete auf eine + Eingabe, die niemand geben konnte; die Weboberfläche kam nie hoch, und im Protokoll + stand nichts davon, weil die Frage auf der Standardausgabe steht. Ein Test auf + `Console.IsInputRedirected` reicht dafür **nicht**: Er beantwortet „ist die Eingabe + umgeleitet", nicht „kann jemand antworten" — ein Prozess ganz ohne Konsole, der + gefährlichste Fall, ist nicht umgeleitet. Der Manager fragt jetzt gar nicht; ein + Schlüssel gehört in `manager.json`. +2. **Eine unlesbare `manager.json` wurde stillschweigend durch Vorgaben ersetzt.** Der + Manager suchte dann im falschen Verzeichnis, hörte auf einem anderen Port und fragte + nach einer Lizenz, die woanders stand — und nichts sagte, woran es lag. Es kostete eine + halbe Stunde und einen fehlenden Backslash. `LoadProblem` meldet den Grund jetzt. +3. **Der Instanzprozess erbte `CLAWD_CONFIG_DIR` vom Manager** und legte sich im + Konfigurationsverzeichnis des Managers eine eigene `Settings.json` an. Eine + Umgebungsvariable vererbt sich, ein Argument nicht — deshalb nimmt der Manager sein + Konfigurationsverzeichnis jetzt über `--config`. Für eine systemd-Unit ist das ohnehin + der geradere Weg. + +**Was in Stufe 4 noch fehlt:** Die Weboberfläche selbst kennt noch keine Instanzen — sie +spricht `/api/…` an und bekommt die eine laufende. Instanzauswahl, `X-Instanz` und die +Verwaltungsansicht sind Arbeit an `wwwroot` und gehören damit in Geminis Spur. Und +`webServerPort` steht noch in der Instanz: Ihr TCP-Zugang ist erst überflüssig, wenn die +Oberfläche im Manager vollständig ist. diff --git a/src/ClawdDotNet.Web/SharedLoggerProvider.cs b/src/ClawdDotNet.Core/Logging/SharedLoggerProvider.cs similarity index 69% rename from src/ClawdDotNet.Web/SharedLoggerProvider.cs rename to src/ClawdDotNet.Core/Logging/SharedLoggerProvider.cs index 1b74159..66c67eb 100644 --- a/src/ClawdDotNet.Web/SharedLoggerProvider.cs +++ b/src/ClawdDotNet.Core/Logging/SharedLoggerProvider.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace ClawdDotNet.Web; +namespace ClawdDotNet.Core.Logging; /// /// Reicht die Protokollierung des Webhosts an die Protokollierung der Anwendung durch. @@ -12,9 +12,12 @@ namespace ClawdDotNet.Web; /// Anwendung protokolliert ab da ins Leere. /// /// Deshalb: durchreichen, und beim Abräumen nichts tun. Die Fabrik gehört -/// dem AppHost, und der räumt sie selbst ab. +/// dem, der sie angelegt hat, und der räumt sie selbst ab. +/// +/// In Core, weil es inzwischen zwei Webhosts gibt, die dasselbe brauchen: den der +/// Instanz und den des Instanzmanagers (#528). Die beiden dürfen einander nicht kennen. /// -internal sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider +public sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider { public ILogger CreateLogger(string categoryName) => shared.CreateLogger(categoryName); diff --git a/src/ClawdDotNet.Manager.Web/AccessToken.cs b/src/ClawdDotNet.Manager.Web/AccessToken.cs new file mode 100644 index 0000000..dd7729b --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/AccessToken.cs @@ -0,0 +1,81 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace ClawdDotNet.Manager.Web; + +/// +/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat. +/// +/// Warum der Manager einen eigenen hat und nicht den der Instanz. Die Instanz +/// hört seit Stufe 2 nur noch auf ihrem Socket, und dort ist die Grenze das Dateisystem — +/// ihr Torwächter kennt deshalb eine Ausnahme für lokale Verbindungen. Hier gibt es die +/// nicht: Alles unter /api und /events kommt über TCP herein, und alles davon +/// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt +/// werden kann. +/// +/// Was ohne Token ausgeliefert wird, ist die leere Hülle — HTML, CSS und +/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert: +/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die +/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte. +/// In den Dateien steht nichts, was jemand nicht wissen dürfte. +/// +/// Warum der Vergleich in fester Zeit läuft: Ein gewöhnlicher +/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst, +/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der +/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand. +/// +public sealed class AccessToken(byte[] expected) +{ + /// Kürzer als das hier darf ein Token nicht sein. + public const int MinimumLength = 20; + + public static AccessToken For(string token) + => new(Encoding.UTF8.GetBytes(token)); + + /// Ob dieser Pfad ein Token braucht: alles unter /api und /events. + public static bool NeedsToken(PathString path) + => path.StartsWithSegments("/api") || path.StartsWithSegments("/events"); + + /// + /// Holt das Token aus der Anfrage. + /// + /// Authorization: Bearer … ist der Weg. Ein Cookie wäre bequemer und + /// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit, + /// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die + /// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier + /// keine eigene Baustelle. + /// + /// Die Ausnahme ist der Ereignisstrom: EventSource im Browser kann keine + /// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der + /// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit + /// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in + /// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse Proxy + /// davor und entscheidet dort, was protokolliert wird. + /// + public static string? Read(HttpRequest request) + { + var header = request.Headers.Authorization.ToString(); + + if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return header["Bearer ".Length..].Trim(); + + if (request.Path.StartsWithSegments("/events") + && request.Query.TryGetValue("token", out var fromQuery)) + return fromQuery.ToString(); + + return null; + } + + public bool Matches(string? candidate) + { + if (string.IsNullOrEmpty(candidate)) return false; + + // FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass + // dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis, + // sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit + // verglichen. + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(candidate), expected); + } +} diff --git a/src/ClawdDotNet.Manager.Web/ClawdDotNet.Manager.Web.csproj b/src/ClawdDotNet.Manager.Web/ClawdDotNet.Manager.Web.csproj new file mode 100644 index 0000000..99083ad --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/ClawdDotNet.Manager.Web.csproj @@ -0,0 +1,39 @@ + + + + net10.0 + enable + enable + ClawdDotNet.Manager.Web + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs new file mode 100644 index 0000000..2e8e0eb --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -0,0 +1,154 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Manager.Web; + +/// Eine Instanz, wie die Weboberfläche sie sieht. +public sealed record InstanceDto(string Id, string Name, string Zustand, int? Prozess); + +/// Was beim Anlegen mitkommt. +public sealed record NewInstanceDto(string Name); + +/// Antwort auf etwas, das gelingen oder scheitern kann. +public sealed record OutcomeDto(bool Ok, string Meldung); + +/// +/// Die HTTP-Fläche des Managers. +/// +/// Zwei Sorten, und der Unterschied ist die ganze Architektur: Unter +/// /api/verwaltung steht, was den Manager selbst angeht — welche Instanzen es gibt, +/// anlegen, starten, anhalten. Alles andere unter /api wird unverändert an eine +/// Instanz weitergereicht. +/// +/// Wer hier einen Endpunkt ergänzt, der etwas über Agenten, Aufgaben oder +/// Freigaben weiß, hat Fachlogik in den Manager gelegt — die gehört in die Instanz, wo +/// die Avalonia-Oberfläche sie auch findet. +/// +internal static class ManagerEndpoints +{ + // ─── Was den Manager selbst angeht ─── + + public static void MapVerwaltung(this WebApplication app) + { + var verwaltung = app.MapGroup("/api/verwaltung"); + + verwaltung.MapGet("/instanzen", (InstanceRegistry registry) => + registry.Instances.Select(Map).ToList()); + + verwaltung.MapPost("/instanzen", async ( + NewInstanceDto neu, ManagerCore manager, CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(neu.Name)) + return Results.BadRequest(new OutcomeDto(false, "Eine Instanz braucht einen Namen.")); + + try + { + var pfad = await manager.CreateInstanceAsync(neu.Name.Trim(), ct); + + return Results.Ok(new OutcomeDto( + true, $"Instanz '{neu.Name.Trim()}' angelegt: {pfad}")); + } + catch (InvalidOperationException ex) + { + // Gibt es schon. Das ist eine Antwort auf eine Frage und keine Stoerung + // des Servers - deshalb 400 und nicht 500. + return Results.BadRequest(new OutcomeDto(false, ex.Message)); + } + }); + + verwaltung.MapPost("/instanzen/{id}/start", async ( + string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) => + { + if (Find(registry, id) is not { } instanz) + return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); + + var ergebnis = await manager.StartInstanceAsync(instanz.Directory, ct); + + return ergebnis switch + { + StartRefusal.None => + Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' läuft.")), + + // 402: Der Grund liegt nicht an der Anfrage, sondern an der Lizenz. + StartRefusal.NoLicense => Results.Json( + new OutcomeDto(false, "Ohne gültige Lizenz startet keine Instanz."), + statusCode: StatusCodes.Status402PaymentRequired), + + _ => Results.Json( + new OutcomeDto(false, + $"Instanz '{instanz.Name}' ist nicht hochgekommen. " + + "Der Grund steht im Protokoll des Managers."), + statusCode: StatusCodes.Status500InternalServerError) + }; + }); + + verwaltung.MapPost("/instanzen/{id}/stop", async ( + string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) => + { + if (Find(registry, id) is not { } instanz) + return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); + + var beendet = await manager.StopInstanceAsync(instanz.Directory, ct); + + return beendet + ? Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' beendet.")) + : Results.Json( + new OutcomeDto(false, + $"Instanz '{instanz.Name}' hört nicht und ist von hier aus nicht zu beenden."), + statusCode: StatusCodes.Status409Conflict); + }); + } + + // ─── Der Ereignisstrom ─── + + public static void MapEvents(this WebApplication app) + { + var logger = app.Services + .GetRequiredService() + .CreateLogger("ClawdDotNet.Manager.Web"); + + // Zwei Stroeme, dieselbe Zusammenfuehrung: die Kernereignisse und das Protokoll. + // Beide gewinnen dadurch, dass an jeder Zeile steht, aus welcher Instanz sie kommt. + app.MapGet("/events", (HttpContext context, InstanceRegistry registry, CancellationToken ct) + => MergedEventStream.StreamAsync(context, registry, "/events", logger, ct)); + + app.MapGet("/events/logs", (HttpContext context, InstanceRegistry registry, CancellationToken ct) + => MergedEventStream.StreamAsync( + context, registry, "/events/logs" + context.Request.QueryString, logger, ct)); + } + + // ─── Alles andere: durchreichen ─── + + public static void MapForwarding(this WebApplication app) + { + app.Map("/api/{**pfad}", async ( + HttpContext context, InstanceRegistry registry, InstanceForwarding forwarding, + CancellationToken ct) => + { + var (instanz, problem) = InstanceRouting.Resolve(registry, context); + + if (instanz is null) + { + // 409 und nicht 404: Der Pfad ist in Ordnung, nur ist unklar oder + // unmoeglich, an wen er gehen soll. + context.Response.StatusCode = StatusCodes.Status409Conflict; + await context.Response.WriteAsJsonAsync(new OutcomeDto(false, problem!), ct); + return; + } + + await forwarding.ForwardAsync(context, instanz, context.Request.Path, ct); + }); + } + + // ─── Kleinkram ─── + + private static InstanceDto Map(ManagedInstance instanz) => new( + InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), instanz.ProcessId); + + private static ManagedInstance? Find(InstanceRegistry registry, string id) + => registry.Instances.FirstOrDefault( + i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/ClawdDotNet.Manager.Web/InstanceForwarding.cs b/src/ClawdDotNet.Manager.Web/InstanceForwarding.cs new file mode 100644 index 0000000..e9b4428 --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/InstanceForwarding.cs @@ -0,0 +1,153 @@ +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); + } + } + + 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(); + } + } +} diff --git a/src/ClawdDotNet.Manager.Web/InstanceRouting.cs b/src/ClawdDotNet.Manager.Web/InstanceRouting.cs new file mode 100644 index 0000000..6b7a844 --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/InstanceRouting.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Http; + +namespace ClawdDotNet.Manager.Web; + +/// +/// Welche Instanz eine Anfrage meint. +/// +/// Der Ordnername ist die Kennung. Er ist eindeutig — zwei Instanzen können +/// nicht im selben Verzeichnis liegen —, er ist stabil, und er ist lesbar. Die +/// Instanzkennung aus der Konfiguration wäre ebenfalls eindeutig, aber man müsste jede +/// Instanzdatei lesen, um von ihr auf ein Verzeichnis zu kommen; der Manager hat den Pfad +/// ohnehin in der Hand. +/// +/// Und wenn nichts dabeisteht? Läuft genau eine Instanz, ist sie gemeint — +/// alles andere wäre Förmlichkeit gegenüber dem Normalfall. Laufen mehrere, wird +/// nachgefragt statt geraten: Eine Anfrage an die falsche Instanz ist schlimmer als eine +/// abgelehnte. +/// +internal static class InstanceRouting +{ + /// Die Kopfzeile, mit der die Weboberfläche die Instanz benennt. + public const string HeaderName = "X-Instanz"; + + public static string IdOf(ManagedInstance instance) => Path.GetFileName(instance.Directory); + + /// Die gemeinte Instanz, oder eine Begründung, warum keine bestimmbar ist. + public static (ManagedInstance? Instance, string? Problem) Resolve( + InstanceRegistry registry, HttpContext context) + { + var alle = registry.Instances; + + if (context.Request.Headers.TryGetValue(HeaderName, out var genannt) + && !string.IsNullOrWhiteSpace(genannt)) + { + var id = genannt.ToString().Trim(); + + var treffer = alle.FirstOrDefault( + i => string.Equals(IdOf(i), id, StringComparison.OrdinalIgnoreCase)); + + return treffer is null + ? (null, $"Es gibt keine Instanz '{id}'.") + : (treffer, null); + } + + var laufend = alle.Where(i => i.Condition == InstanceCondition.Running).ToList(); + + return laufend.Count switch + { + 1 => (laufend[0], null), + 0 => (null, "Es läuft keine Instanz."), + _ => (null, $"Es laufen {laufend.Count} Instanzen. " + + $"Welche gemeint ist, gehört in die Kopfzeile {HeaderName}.") + }; + } +} diff --git a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs new file mode 100644 index 0000000..5212aef --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs @@ -0,0 +1,228 @@ +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, 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, 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, + 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(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(); + } +} diff --git a/src/ClawdDotNet.Manager.Web/MergedEventStream.cs b/src/ClawdDotNet.Manager.Web/MergedEventStream.cs new file mode 100644 index 0000000..5e77e96 --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/MergedEventStream.cs @@ -0,0 +1,217 @@ +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using ClawdDotNet.Core.Instances; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Manager.Web; + +/// +/// N Instanzen, ein Ereignisstrom zum Browser. +/// +/// Die eine Stelle, die wirklich neu gedacht werden musste (Konzept, +/// Abschnitt 8). Alles andere an der Weboberfläche ließ sich weiterleiten; ein +/// Ereignisstrom nicht. Der Browser hält eine Verbindung, die Instanzen halten je eine, +/// und dazwischen muss jemand zusammenführen — und an jedes Ereignis schreiben, von wem +/// es kommt. Ohne das wäre in einem Fenster mit drei Instanzen nicht zu erkennen, welcher +/// Agent gerade spricht. +/// +/// Ergänzt statt verpackt. Es wäre einfacher, jedes Ereignis in einen +/// Umschlag zu stecken ({"instanz":…,"daten":…}) — dann müsste aber jede Ansicht +/// der Weboberfläche umgeschrieben werden. Stattdessen bekommt das vorhandene Objekt ein +/// Feld instanz dazu; alles, was heute Felder liest, liest sie weiter. +/// +/// Eine tote Instanz reißt den Strom nicht ab. Fällt eine weg, endet ihre +/// Zuleitung, und die anderen laufen weiter. Der Browser merkt davon nichts — was er +/// merken soll, sagt ihm die Aufsicht als eigenes Ereignis. +/// +internal static class MergedEventStream +{ + /// + /// Wie viele Ereignisse ein langsamer Browser hinter sich herziehen darf, bevor die + /// ältesten wegfallen. + /// + /// Begrenzt und verwerfend, nicht unbegrenzt: Ein Tab, der im Hintergrund + /// eingefroren ist, würde sonst den Speicher des Managers füllen. Ein verlorener + /// Zwischenstand ist der bessere Verlust — die Seite lädt den Verlauf beim Verbinden + /// ohnehin neu. + /// + private const int QueueDepth = 512; + + public static async Task StreamAsync( + HttpContext context, + InstanceRegistry registry, + string instancePath, + ILogger logger, + CancellationToken ct) + { + var queue = Channel.CreateBounded(new BoundedChannelOptions(QueueDepth) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true + }); + + var laufend = registry.Instances + .Where(i => i.Condition == InstanceCondition.Running) + .ToList(); + + using var leben = CancellationTokenSource.CreateLinkedTokenSource(ct); + + var zuleitungen = laufend + .Select(instanz => PumpAsync(instanz, instancePath, queue.Writer, logger, leben.Token)) + .ToList(); + + PrepareResponse(context); + + // Sofort etwas schicken: Erst damit steht die Verbindung fuer den Browser, und + // onopen feuert. Ohne das haengt die Seite im Ungewissen, bis das erste Ereignis + // von selbst kommt - was Stunden dauern kann. + await context.Response.WriteAsync( + $": bereit ({laufend.Count} Instanzen)\n\n", ct).ConfigureAwait(false); + + await context.Response.Body.FlushAsync(ct).ConfigureAwait(false); + + using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20)); + + try + { + var naechstes = queue.Reader.ReadAsync(ct).AsTask(); + var schlag = heartbeat.WaitForNextTickAsync(ct).AsTask(); + + while (true) + { + var fertig = await Task.WhenAny(naechstes, schlag).ConfigureAwait(false); + + if (fertig == naechstes) + { + await context.Response.WriteAsync(await naechstes, ct).ConfigureAwait(false); + naechstes = queue.Reader.ReadAsync(ct).AsTask(); + } + else + { + if (!await schlag) break; + + // Ein Kommentar haelt die Verbindung offen, ohne ein Ereignis + // vorzutaeuschen. Ohne ihn kappt so mancher Proxy nach einer Minute. + await context.Response.WriteAsync(": still\n\n", ct).ConfigureAwait(false); + schlag = heartbeat.WaitForNextTickAsync(ct).AsTask(); + } + + await context.Response.Body.FlushAsync(ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Der Browser hat aufgelegt, oder der Manager faehrt herunter. + } + catch (ChannelClosedException) + { + // Der Strom wird abgeraeumt. + } + finally + { + await leben.CancelAsync().ConfigureAwait(false); + queue.Writer.TryComplete(); + + try { await Task.WhenAll(zuleitungen).ConfigureAwait(false); } + catch { /* Abgebrochene Zuleitungen sind der Normalfall beim Aufraeumen. */ } + } + } + + /// + /// Eine Zuleitung: hört am Socket einer Instanz und schiebt weiter, was kommt. + /// + private static async Task PumpAsync( + ManagedInstance instanz, + string instancePath, + ChannelWriter ziel, + ILogger logger, + CancellationToken ct) + { + var id = InstanceRouting.IdOf(instanz); + + try + { + using var client = InstanceEndpoint.CreateClient(instanz.Directory); + + // Kein Zeitlimit: Ein Ereignisstrom endet nie von selbst - das ist sein Zweck. + client.Timeout = Timeout.InfiniteTimeSpan; + + using var antwort = await client.GetAsync( + instancePath, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + + antwort.EnsureSuccessStatusCode(); + + await using var strom = await antwort.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var leser = new StreamReader(strom, Encoding.UTF8); + + string? art = null; + + while (await leser.ReadLineAsync(ct).ConfigureAwait(false) is { } zeile) + { + if (zeile.StartsWith(": ", StringComparison.Ordinal)) + continue; // Herzschlag der Instanz. Der Manager hat einen eigenen. + + if (zeile.StartsWith("event: ", StringComparison.Ordinal)) + { + art = zeile["event: ".Length..]; + continue; + } + + if (!zeile.StartsWith("data: ", StringComparison.Ordinal)) + continue; + + ziel.TryWrite(Format(art ?? "message", zeile["data: ".Length..], id, instanz.Name)); + art = null; + } + } + catch (OperationCanceledException) + { + // Der Strom wird abgeraeumt oder der Browser hat aufgelegt. + } + catch (Exception ex) + { + // Eine Instanz, die waehrend des Zuhoerens weggeht, ist kein Fehler des + // Managers - und sie darf die anderen Zuleitungen nicht mitnehmen. + logger.LogInformation( + "Ereigniszuleitung von Instanz {Name} beendet: {Grund}", instanz.Name, ex.Message); + } + } + + /// + /// Hängt instanz und instanzName an das Ereignis. + /// + /// Ist der Inhalt kein Objekt — oder gar kein JSON —, wird er unverändert + /// weitergereicht. Ein Ereignis zu verschlucken, weil sein Inhalt unerwartet aussieht, + /// wäre die schlechtere Antwort: Der Manager leitet weiter, er urteilt nicht. + /// + private static string Format(string art, string daten, string id, string name) + { + try + { + if (JsonNode.Parse(daten) is JsonObject objekt) + { + objekt["instanz"] = id; + objekt["instanzName"] = name; + return $"event: {art}\ndata: {objekt.ToJsonString()}\n\n"; + } + } + catch (System.Text.Json.JsonException) + { + // Siehe oben. + } + + return $"event: {art}\ndata: {daten}\n\n"; + } + + private static void PrepareResponse(HttpContext context) + { + context.Response.Headers.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache"; + + // Ohne das puffert ein vorgelagerter nginx den Strom und liefert ihn erst, wenn + // er voll ist - aus einem Ereignisstrom wird dann eine Ereignissammlung. + context.Response.Headers["X-Accel-Buffering"] = "no"; + } +} diff --git a/src/ClawdDotNet.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/app.css rename to src/ClawdDotNet.Manager.Web/wwwroot/app.css diff --git a/src/ClawdDotNet.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/app.js rename to src/ClawdDotNet.Manager.Web/wwwroot/app.js diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/barlow-regular.ttf b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlow-regular.ttf similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/fonts/barlow-regular.ttf rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlow-regular.ttf diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/barlow-semibold.ttf b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlow-semibold.ttf similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/fonts/barlow-semibold.ttf rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlow-semibold.ttf diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/barlowcondensed-regular.ttf b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlowcondensed-regular.ttf similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/fonts/barlowcondensed-regular.ttf rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlowcondensed-regular.ttf diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/barlowcondensed-semibold.ttf b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlowcondensed-semibold.ttf similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/fonts/barlowcondensed-semibold.ttf rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlowcondensed-semibold.ttf diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/ofl.txt b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/ofl.txt similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/fonts/ofl.txt rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/ofl.txt diff --git a/src/ClawdDotNet.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/index.html rename to src/ClawdDotNet.Manager.Web/wwwroot/index.html diff --git a/src/ClawdDotNet.Web/wwwroot/modules/api.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/modules/api.js rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js diff --git a/src/ClawdDotNet.Web/wwwroot/modules/dom.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/modules/dom.js rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js diff --git a/src/ClawdDotNet.Web/wwwroot/modules/events.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/events.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/modules/events.js rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/events.js diff --git a/src/ClawdDotNet.Web/wwwroot/modules/router.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/router.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/modules/router.js rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/router.js diff --git a/src/ClawdDotNet.Web/wwwroot/modules/theme.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/theme.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/modules/theme.js rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/theme.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/agents.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/agents.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/chat.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/chat.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/info.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/info.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/info.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/info.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/login.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/login.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/login.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/login.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/logs.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/logs.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/logs.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/logs.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/placeholders.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/placeholders.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/placeholders.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/placeholders.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/services.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/services.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/services.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/services.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/staging.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/staging.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/tasks.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/tasks.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js diff --git a/src/ClawdDotNet.Web/wwwroot/views/usage.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/usage.js similarity index 100% rename from src/ClawdDotNet.Web/wwwroot/views/usage.js rename to src/ClawdDotNet.Manager.Web/wwwroot/views/usage.js diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs index 3c02349..744088d 100644 --- a/src/ClawdDotNet.Manager/ManagerSettings.cs +++ b/src/ClawdDotNet.Manager/ManagerSettings.cs @@ -143,8 +143,23 @@ public sealed class ManagerSettingsStore public ManagerSettings Settings { get; private set; } = new(); + /// + /// Warum die Datei nicht gelesen werden konnte — oder null, wenn alles ging. + /// + /// Warum das nach aussen muss. Eine unlesbare Datei verhindert den Start + /// nicht; das wäre der schlechtere Zustand, denn dann liefe gar keine Instanz mehr. + /// Aber „läuft weiter" heisst nicht „niemand erfährt es": Mit Vorgaben statt der + /// eigenen Einstellungen sucht der Manager im falschen Verzeichnis nach Instanzen, + /// hört auf einem anderen Port und fragt nach einer Lizenz, die woanders steht — und + /// nichts davon sagt, woran es liegt. Genau das ist beim ersten Abnahmelauf passiert: + /// ein einzelner Backslash zu wenig, und eine Viertelstunde Ratelei. + /// + public string? LoadProblem { get; private set; } + public ManagerSettings Load() { + LoadProblem = null; + if (!File.Exists(SettingsPath)) { Settings = new ManagerSettings(); @@ -160,7 +175,9 @@ public sealed class ManagerSettingsStore catch (Exception ex) when (ex is JsonException or IOException) { // Eine unlesbare Datei darf den Manager nicht am Start hindern - dann liefe - // gar keine Instanz mehr, und das ist der schlechtere Zustand. + // gar keine Instanz mehr, und das ist der schlechtere Zustand. Aber der Grund + // gehoert nach draussen, sonst arbeitet er stillschweigend mit Vorgaben. + LoadProblem = ex.Message; Settings = new ManagerSettings(); return Settings; } diff --git a/src/ClawdDotNet.ManagerApp/ClawdDotNet.ManagerApp.csproj b/src/ClawdDotNet.ManagerApp/ClawdDotNet.ManagerApp.csproj new file mode 100644 index 0000000..dacc8af --- /dev/null +++ b/src/ClawdDotNet.ManagerApp/ClawdDotNet.ManagerApp.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + ClawdDotNet.ManagerApp + + + ClawdDotNet.Instanzmanager + + + + + + + + diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs new file mode 100644 index 0000000..61a0f38 --- /dev/null +++ b/src/ClawdDotNet.ManagerApp/Program.cs @@ -0,0 +1,272 @@ +using System.Runtime.InteropServices; +using ClawdDotNet.Core.Deploymentcenter; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Core.Logging; +using ClawdDotNet.Licensing; +using ClawdDotNet.Manager; +using ClawdDotNet.Manager.Web; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.ManagerApp; + +/// +/// Der Instanzmanager als Prozess (#528, Stufe 4). +/// +/// Ein Prozess je Maschine. Er startet Instanzen, beaufsichtigt sie, meldet +/// stellvertretend, was sie nicht mehr melden können, und trägt die eine Weboberfläche für +/// alle. Er hält keine Datenbank, führt keine Werkzeuge aus und kennt keine Fachlogik — +/// das alles läuft in den Instanzprozessen. +/// +/// Und er beendet beim Herunterfahren keine Instanz (E-M6). Wer den Manager +/// schließt, will den Manager schließen. Läuft er wieder an, findet er die laufenden über +/// ihren Socket wieder. Das ist der Grund, warum er nicht der Einzelpunkt ist, gegen den +/// dieses Vorhaben gebaut wurde. +/// +internal static class Program +{ + private const int ExitOk = 0; + private const int ExitNoWebUi = 2; + + /// Wo manager.json liegt, wenn nicht am üblichen Ort. + private const string ConfigArgument = "--config"; + + public static async Task Main(string[] args) + { + var settings = new ManagerSettingsStore(ReadConfigDirectory(args)); + settings.Load(); + + var logDirectory = Path.Combine( + ClawdDotNet.Core.Storage.AppPaths.DataDirectory, "Logs", "Manager"); + + using var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory( + logDirectory, ClawdDotNet.Core.Logging.LogLevel.Info); + + var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager"); + + logger.LogInformation("Instanzmanager startet."); + logger.LogInformation("Einstellungen: {Pfad}", settings.SettingsPath); + + if (settings.LoadProblem is { } problem) + { + logger.LogError( + "Die Einstellungen unter {Pfad} liessen sich nicht lesen ({Grund}). " + + "Der Manager arbeitet mit Vorgaben — Instanzverzeichnis, Port und Lizenz " + + "stehen damit anders, als in der Datei steht.", + settings.SettingsPath, problem); + } + + logger.LogInformation("Protokoll: {Pfad}", logDirectory); + logger.LogInformation("Instanzen: {Pfad}", settings.Settings.InstancesDirectory); + + // Was tatsaechlich gilt, nicht was in der Datei steht: Beim Abnahmelauf hat eine + // halbe Stunde gefehlt, weil beides auseinanderlief und nichts es sagte. + logger.LogInformation( + "Weboberfläche: Port {Port}, Bindung {Bindung}, Token {Token}. " + + "Instanz-Programmdatei: {Exe}", + settings.Settings.WebUiPort, + settings.Settings.WebUiBinding, + string.IsNullOrWhiteSpace(settings.Settings.WebUiToken) + ? "fehlt" + : $"{settings.Settings.WebUiToken.Length} Zeichen", + string.IsNullOrWhiteSpace(settings.Settings.InstanceExecutable) + ? "(neben dem Manager)" + : settings.Settings.InstanceExecutable); + + await using var manager = Build(settings, loggerFactory, logger); + + if (!await manager.StartAsync()) + logger.LogWarning("Der Manager läuft ohne gültige Lizenz — er startet keine Instanz."); + + await using var web = ManagerWebHost.TryStart(manager, loggerFactory); + + if (web is null) + { + // Ohne Weboberflaeche hat dieser Prozess keinen Zugang: Ein Fenster hat er + // noch nicht (Stufe 5), und ohne Zugang laesst sich nichts starten und nichts + // ansehen. Dann ist Beenden ehrlicher als Weiterlaufen. + logger.LogError( + "Kein Zugang zum Manager: Die Weboberfläche ist nicht hochgekommen. " + + "Ohne sie lässt sich nichts bedienen — der Manager beendet sich."); + + return ExitNoWebUi; + } + + Console.WriteLine(web.Url); + logger.LogInformation("Instanzmanager bereit. Weboberfläche: {Url}", web.Url); + + await WaitForShutdownAsync(logger); + + logger.LogInformation( + "Instanzmanager wird beendet. Laufende Instanzen laufen weiter."); + + return ExitOk; + } + + private static ManagerCore Build( + ManagerSettingsStore settings, ILoggerFactory loggerFactory, ILogger logger) + { + var s = settings.Settings; + + var directories = new InstanceDirectoryManager( + Path.GetFullPath(s.InstancesDirectory)); + + var launcher = new ProcessInstanceLauncher(ResolveInstanceExecutable(s, logger)); + var channel = new SocketInstanceChannel(); + + var registry = new InstanceRegistry( + directories, launcher, channel, loggerFactory, s.ToSupervisionOptions()); + + // ─── Deploymentcenter: Sub-Token und stellvertretende Meldung ─── + IInstanceTokenSource tokens = new NoInstanceTokenSource(); + SupervisionReporter? reporter = null; + + if (!string.IsNullOrWhiteSpace(s.DeploymentcenterMasterToken)) + { + var api = new DeploymentcenterApi(s.DeploymentcenterUrl, s.DeploymentcenterMasterToken); + + tokens = new DeploymentcenterTokenSource( + directories, api, loggerFactory.CreateLogger("ClawdDotNet.Manager.Token")); + + reporter = new SupervisionReporter( + new BugtrackerClient(api, LicenseInfo.ProductSlug, "production", Version()), + loggerFactory.CreateLogger("ClawdDotNet.Manager.Meldung")); + } + else + { + logger.LogInformation( + "Kein Deploymentcenter-Token hinterlegt: keine Sub-Token, keine " + + "stellvertretenden Meldungen. Befunde stehen im Protokoll."); + } + + // ─── Lizenz (E-M3) ─── + LicenseGate? gate = null; + LicenseWatch? watch = null; + + if (!string.IsNullOrWhiteSpace(s.LicenseKey) || !string.IsNullOrWhiteSpace(s.DeploymentcenterUrl)) + { + gate = new LicenseGate( + new ManagerLicenseStore(settings), + loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz"), + LicensePrompt(logger), + Version()); + + watch = new LicenseWatch(gate, loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz")); + } + + return new ManagerCore(settings, registry, tokens, loggerFactory, reporter, gate, watch); + } + + /// + /// Das Verzeichnis hinter --config, oder null für den üblichen Ort. + /// + /// Wozu, wenn es CLAWD_CONFIG_DIR gibt. Weil eine Umgebungsvariable + /// an Kindprozesse vererbt wird und ein Argument nicht. Der Manager startet Instanzen; + /// mit der Variablen läse jede von ihnen ihre Anwendungseinstellungen aus dem + /// Verzeichnis des Managers — nachgemessen, sie legt sich dort eine eigene + /// Settings.json an. Ein Argument sagt nur diesem einen Prozess, wo seine Datei + /// liegt, und genau das ist gemeint. + /// + /// Für eine systemd-Unit ist es ohnehin der geradere Weg als eine + /// Environment=-Zeile. + /// + private static string? ReadConfigDirectory(string[] args) + { + for (var i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], ConfigArgument, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + + return null; + } + + /// + /// Wie der Manager nach einem Lizenzschlüssel fragt: gar nicht. + /// + /// Er ist ein Hintergrundprozess. Er wird von einem Dienst gestartet, von einem + /// Skript oder von einem Fenster, das es noch nicht gibt (Stufe 5) — in keinem dieser + /// Fälle sitzt jemand an seiner Konsole. Console.ReadLine steht dort für immer, + /// ohne dass jemand die Frage je zu sehen bekommt, und weil der Manager die Instanzen + /// startet, steht damit die ganze Maschine, nicht bloss eine Anwendung. + /// + /// Genau so ist der erste Abnahmelauf ausgegangen: Der Prozess hing an + /// „Lizenzschlüssel: ", die Weboberfläche kam nie hoch, und im Protokoll stand nichts + /// davon — die Frage steht auf der Standardausgabe. + /// + /// Ein Test auf Console.IsInputRedirected reicht nicht. Er + /// beantwortet „ist die Eingabe umgeleitet", nicht „kann jemand antworten": Ein + /// Prozess ganz ohne Konsole — der gefährlichste Fall — ist nicht umgeleitet. Wo die + /// falsche Antwort einen Aufpasser für immer anhält, ist die Frage die falsche Frage. + /// Ein Schlüssel gehört in manager.json, hineingeschrieben von der Einrichtung + /// oder vom Fenster. + /// + private static ILicensePrompt LicensePrompt(ILogger logger) + => new NonInteractiveLicensePrompt(text => logger.LogWarning("{Meldung}", text)); + + /// + /// Wo die Programmdatei der Instanz liegt. + /// + /// Ohne Eintrag: neben der des Managers. Das ist der Normalfall — beide kommen + /// aus demselben Paket —, und ein Eintrag ist die Ausnahme für eine Entwicklungsablage, + /// in der die beiden auseinanderliegen. + /// + private static string ResolveInstanceExecutable(ManagerSettings settings, ILogger logger) + { + if (!string.IsNullOrWhiteSpace(settings.InstanceExecutable)) + return Path.GetFullPath(settings.InstanceExecutable); + + var name = OperatingSystem.IsWindows() ? "ClawdDotNet.exe" : "ClawdDotNet"; + var daneben = Path.Combine(AppContext.BaseDirectory, name); + + if (!File.Exists(daneben)) + { + // Nicht abbrechen: Der Manager kann trotzdem beaufsichtigen, was schon laeuft, + // und die Weboberflaeche zeigt es an. Nur starten kann er dann nichts - und + // das soll man wissen, bevor man es versucht. + logger.LogWarning( + "Die Programmdatei der Instanzen liegt nicht unter {Pfad}. Ohne sie lässt " + + "sich keine Instanz starten (instanceExecutable in manager.json).", daneben); + } + + return daneben; + } + + private static string Version() + => typeof(Program).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + /// + /// Wartet, bis jemand Schluss sagt. + /// + /// Das Signal wird abgefangen, nicht durchgelassen + /// (context.Cancel = true). Sonst räumte die Laufzeit den Prozess ab, während + /// das geordnete Herunterfahren noch läuft — und genau dabei wird der Schreibpuffer des + /// Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529, nur diesmal + /// beim Aufpasser. + /// + private static async Task WaitForShutdownAsync(ILogger logger) + { + var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void Beenden(string grund) + { + if (schluss.TrySetResult()) + logger.LogInformation("Beendigung angefordert ({Grund}).", grund); + } + + using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => + { + context.Cancel = true; + Beenden("SIGTERM"); + }); + + using var sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, context => + { + context.Cancel = true; + Beenden("SIGINT"); + }); + + AppDomain.CurrentDomain.ProcessExit += (_, _) => Beenden("ProcessExit"); + + await schluss.Task; + } +} diff --git a/src/ClawdDotNet.Web/InstanceHttpHost.cs b/src/ClawdDotNet.Web/InstanceHttpHost.cs index 265689b..7e2e25f 100644 --- a/src/ClawdDotNet.Web/InstanceHttpHost.cs +++ b/src/ClawdDotNet.Web/InstanceHttpHost.cs @@ -161,7 +161,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable // Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen. builder.Logging.ClearProviders(); - builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory)); + builder.Logging.AddProvider(new Core.Logging.SharedLoggerProvider(loggerFactory)); // ASP.NET protokolliert auf Information jede einzelne Anfrage - mit eigener // Zeile fuer Start und Ende, und das auch fuer jede Schriftart und jedes diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs index 0f10580..5174ce4 100644 --- a/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs +++ b/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs @@ -53,6 +53,40 @@ public sealed class ManagerSettingsTests : IDisposable erneut.LicenseKey.ShouldBe("LIZ-1234-5678"); } + /// + /// Eine von Hand oder von PowerShell geschriebene Datei hat oft ein Byte-Order-Mark, + /// und sie darf trotzdem gelten. + /// + /// Beim Abnahmelauf sah es aus, als würde sie ignoriert — der Manager arbeitete + /// mit Vorgaben. Dieser Test klärt, ob das an der Datei lag. + /// + [Fact] + public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch() + { + var pfad = Path.Combine(_dir, "manager.json"); + + // Genau so, wie PowerShell es schreibt: mit BOM, mit doppelten Leerzeichen nach + // dem Doppelpunkt, und mit verdoppelten Backslashes im Pfad. + var inhalt = """ + { + "instanceExecutable": "J:\\Programme\\ClawdDotNet.exe", + "webUiPort": 7009, + "deploymentcenterUrl": "" + } + """; + + File.WriteAllText(pfad, inhalt, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + File.ReadAllBytes(pfad)[0].ShouldBe((byte)0xEF, "Der Test braucht das BOM."); + + var store = new ManagerSettingsStore(_dir); + var settings = store.Load(); + + store.LoadProblem.ShouldBeNull(); + settings.InstanceExecutable.ShouldBe(@"J:\Programme\ClawdDotNet.exe"); + settings.WebUiPort.ShouldBe(7009); + settings.DeploymentcenterUrl.ShouldBe(""); + } + /// /// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar /// keine Instanz mehr, und das ist der schlechtere Zustand. @@ -62,7 +96,13 @@ public sealed class ManagerSettingsTests : IDisposable { File.WriteAllText(Path.Combine(_dir, "manager.json"), "{ das ist kein JSON"); - new ManagerSettingsStore(_dir).Load().WebUiPort.ShouldBe(7000); + var store = new ManagerSettingsStore(_dir); + + store.Load().WebUiPort.ShouldBe(7000); + + // Und der Grund steht fest - sonst arbeitet der Manager stillschweigend mit + // Vorgaben, und niemand kann sagen, warum sein Instanzverzeichnis falsch ist. + store.LoadProblem.ShouldNotBeNullOrWhiteSpace(); } ///