From 8686d13999914bd98f1af8ffd7ae988a87af7ae4 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 8 Sep 2026 22:08:23 +0200 Subject: [PATCH] Instanzmanager Stufe 2: Kestrel auf dem Socket, Instanz kopflos (#528) Eine Instanz ist jetzt ohne Port ansprechbar. InstanceEndpoint liefert die Adresse: unter Linux /manager.sock, unter Windows eine Named Pipe, deren Name aus einem SHA-256-Fingerabdruck des Instanzpfades entsteht. Zwei Instanzen bekommen damit zwangslaeufig zwei Adressen, ohne dass jemand etwas vergeben muss - genau der Unterschied zur Vorbelegung 7001, um die sich zwei Instanzen stillschweigend streiten. Der Socket ist immer da, auch ohne Weboberflaeche. Er gehoert nicht zu #494, sondern ist die Steuerflaeche der Instanz: webUiEnabled schaltet den TCP-Zugang, nicht den Socket. WebUiHost heisst deshalb jetzt InstanceHttpHost - ein Kestrel, zwei Zugaenge. Ueber TCP gilt Token-Pflicht, ueber den Socket nicht: Dort steht die Grenze am Dateisystem (0600 unter Linux, CurrentUserOnly bei der Named Pipe, explizit gesetzt statt auf die Vorgabe vertraut). Wer die Socketdatei oeffnen darf, sieht ohnehin in das Verzeichnis, in dem alles liegt, was ein Token schuetzen wuerde. Woran die beiden Zugaenge auseinandergehalten werden, ist der Punkt, an dem ein Irrtum teuer waere. Bei Socket-Verbindungen stehen in HttpContext.Connection keine IP-Adressen - daraus zu schliessen "keine IP, also Socket" waere eine Vermutung an der Stelle, an der ein Torwaechter steht, und sie irrt in die falsche Richtung: Im Zweifel liesse sie eine TCP-Anfrage ohne Token durch. Der Socket-Zuhoerer haengt deshalb jeder Verbindung selbst eine Marke an (LocalTransport, Verbindungs- Zwischenschicht ueber ListenOptions.Use); wo sie fehlt, gilt Token-Pflicht. Ein belegter Port kostet nur noch die Weboberflaeche. Vorher gab TryStart in dem Fall null zurueck und die ganze HTTP-Flaeche war weg. Seit dem Socket ist das die falsche Rangfolge: Ihn zu verlieren, weil eine zweite Instanz laeuft, hiesse die Instanz unerreichbar zu machen - in genau dem Fall, fuer den es den Manager gibt. Kopfloser Betrieb ueber --headless --instance . Die Verzweigung sitzt in Program.Main vor BuildAvaloniaApp, damit auf einem Server ohne Bildschirmsitzung kein Fenstersystem angefasst wird. Gefragt wird nichts: Instanz aus dem Argument, Lizenz ueber das bereits vorhandene NonInteractiveLicensePrompt, Telegram-Anmeldung aus. Nachgemessen, nicht angenommen: Kopflose Instanz auf Instance-Test gestartet, kein Fenster, und eine rohe HTTP/1.1-Anfrage aus einem fremden Prozess (PowerShell, direkt auf die Pipe) bekam 200 OK auf /api/info, ohne Token. Das ist die Abnahmebedingung fuer Stufe 2. Korrektur zur eigenen Ankuendigung: webServerPort entfaellt nicht mit Stufe 2, sondern mit Stufe 4. Solange kein Manager die Weboberflaeche traegt, hiesse Wegnehmen, sie ersatzlos abzuschalten. Zwei Luecken bleiben offen und gehoeren auf die Linux-Testmaschine: Die Socketdatei-Faelle sind unter Windows gegenstandslos und werden dort mit sichtbarer Begruendung uebersprungen - der Unix-Domain-Socket selbst ist auf der Entwicklungsmaschine also ungeprueft, obwohl er die Zielplattform ist. Und das geordnete Ende auf SIGTERM liess sich hier nicht ausloesen: Windows kennt das Signal fuer einen fensterlosen Prozess nicht. Build ohne Warnungen, 714 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 --- docs/Instanzmanager-Konzept.md | 54 ++- docs/WebUI-Leitfaden.md | 15 + src/ClawdDotNet.Desktop/App.axaml.cs | 26 +- src/ClawdDotNet.Desktop/CommandLine.cs | 42 +++ src/ClawdDotNet.Desktop/HeadlessRun.cs | 138 +++++++ src/ClawdDotNet.Desktop/Program.cs | 11 +- src/ClawdDotNet.Web/Contracts/Dtos.cs | 2 +- src/ClawdDotNet.Web/InstanceHttpHost.cs | 349 ++++++++++++++++++ .../Security/LocalTransport.cs | 47 +++ src/ClawdDotNet.Web/WebUiHost.cs | 237 ------------ src/ClawdDotNet.Web/WebUiOptions.cs | 10 +- .../Infrastructure/PlatformFacts.cs | 26 ++ .../InstanceSocketTests.cs | 189 ++++++++++ tests/ClawdDotNet.Web.Tests/ShutdownTests.cs | 30 +- 14 files changed, 903 insertions(+), 273 deletions(-) create mode 100644 src/ClawdDotNet.Desktop/CommandLine.cs create mode 100644 src/ClawdDotNet.Desktop/HeadlessRun.cs create mode 100644 src/ClawdDotNet.Web/InstanceHttpHost.cs create mode 100644 src/ClawdDotNet.Web/Security/LocalTransport.cs delete mode 100644 src/ClawdDotNet.Web/WebUiHost.cs create mode 100644 tests/ClawdDotNet.Web.Tests/Infrastructure/PlatformFacts.cs create mode 100644 tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md index 322cb55..b0126c6 100644 --- a/docs/Instanzmanager-Konzept.md +++ b/docs/Instanzmanager-Konzept.md @@ -312,7 +312,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis. | Stufe | Inhalt | Woran man es misst | |---|---|---| | 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 | +| 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 | | 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln | @@ -336,11 +336,10 @@ einer alten `Settings.json`, meldet `SettingsManager.AbandonedKeys` sie, und der protokolliert einmal, was nicht mehr gilt und wohin diese Instanz stattdessen schreibt. Beim nächsten Speichern verschwinden sie aus der Datei. -**`webServerPort` bleibt vorerst stehen.** Es entfällt erst, wenn Kestrel auf dem Socket -hört — also mit Stufe 2. Vorher wegzunehmen hieße, die Weboberfläche ohne Ersatz -abzuschalten. Der Portstreit zweier Instanzen ist bis dahin gedeckelt, aber nicht gelöst: -`WebUiHost.TryStart` gibt bei belegtem Port `null` zurück und protokolliert es, statt den -Start zu verhindern. +**`webServerPort` bleibt vorerst stehen.** Es entfällt erst mit **Stufe 4**, wenn der +Manager den Port übernimmt — nicht schon, sobald Kestrel den Socket hat. Eine frühere +Fassung dieses Absatzes sagte „mit Stufe 2"; das war falsch gerechnet: Solange es keinen +Manager gibt, der die Weboberfläche trägt, hieße Wegnehmen, sie ersatzlos abzuschalten. --- @@ -357,3 +356,46 @@ Manager läuft immer mit, die Instanzfenster laufen mit, aber eine Instanz *kann ohne Fenster laufen. **#218 (SDK 2.6.0 → aktuell)** wird Voraussetzung — siehe Abschnitt 5. + +### 9.2 Was Stufe 2 gebracht hat + +**Kestrel hört auf dem Socket** — `/manager.sock` unter Linux, unter Windows +eine Named Pipe, deren Name aus einem Fingerabdruck des Instanzpfades entsteht +(`InstanceEndpoint`). Zwei Instanzen bekommen zwangsläufig zwei Adressen, ohne dass +jemand etwas vergeben müsste. + +**Der Socket ist immer da, auch ohne Weboberfläche.** Er ist nicht Teil von #494, sondern +die Steuerfläche der Instanz: `webUiEnabled` schaltet den TCP-Zugang, nicht den Socket. +Was aus `WebUiHost` wurde, heißt deshalb jetzt `InstanceHttpHost` — ein Kestrel, zwei +Zugänge. + +**Der Torwächter unterscheidet die Zugänge.** Über TCP gilt Token-Pflicht, über den +Socket nicht: Dort steht die Grenze am Dateisystem (0600 beziehungsweise +`CurrentUserOnly`), und wer die Socketdatei öffnen darf, sieht ohnehin in das Verzeichnis, +in dem alles liegt, was ein Token schützen würde. **Woran die beiden auseinandergehalten +werden, ist der Punkt, an dem ein Irrtum teuer wäre:** Bei Socket-Verbindungen stehen in +`HttpContext.Connection` keine IP-Adressen — daraus zu schließen „keine IP, also Socket" +wäre eine Vermutung an der Stelle, an der ein Torwächter steht, und ließe im Zweifel eine +TCP-Anfrage ohne Token durch. Der Socket-Zuhörer hängt deshalb jeder Verbindung selbst +eine Marke an (`LocalTransport`); wo sie fehlt, gilt Token-Pflicht. + +**Ein belegter Port kostet nur noch die Weboberfläche.** Vorher gab `TryStart` in dem Fall +`null` zurück und die ganze HTTP-Fläche war weg. Das ist seit dem Socket die falsche +Rangfolge: Ihn zu verlieren, weil eine zweite Instanz schon läuft, hieße die Instanz +unerreichbar zu machen — in genau dem Fall, für den es den Manager gibt. + +**Kopfloser Betrieb** über `--headless --instance `. Die Verzweigung sitzt in +`Program.Main` vor `BuildAvaloniaApp`, damit auf einem Server ohne Bildschirmsitzung kein +Fenstersystem angefasst wird. Gefragt wird nichts: Instanz aus dem Argument, Lizenz über +`NonInteractiveLicensePrompt`, Telegram-Anmeldung aus. + +Nachgemessen, nicht angenommen: Eine kopflose Instanz auf `Instance-Test` lief ohne +Fenster, und eine rohe HTTP-Anfrage aus einem fremden Prozess (PowerShell, direkt auf die +Pipe) bekam `200 OK` auf `/api/info` — ohne Token. + +**Zwei Lücken, die offen bleiben.** Die Socketdatei-Fälle (liegengebliebene Leiche, +Aufräumen beim Beenden) sind unter Windows gegenstandslos und werden dort mit Begründung +übersprungen — der Unix-Domain-Socket selbst ist auf der Entwicklungsmaschine also +ungeprüft, obwohl er die Zielplattform ist. Und das geordnete Ende auf `SIGTERM` ließ sich +hier nicht auslösen: Windows kennt das Signal für einen fensterlosen Prozess nicht. +Beides gehört auf die Linux-Testmaschine. diff --git a/docs/WebUI-Leitfaden.md b/docs/WebUI-Leitfaden.md index 8aeaa99..4f6742d 100644 --- a/docs/WebUI-Leitfaden.md +++ b/docs/WebUI-Leitfaden.md @@ -78,6 +78,21 @@ Startet sie nicht, steht der Grund im Protokoll (`Logs/`, Modul `Web`). Die häufigsten: Token fehlt, Token kürzer als 20 Zeichen, Port belegt. Ein Fehlschlag hält die Anwendung nie auf — sie läuft dann eben ohne Browser. +### Der Socket daneben — nicht deine Baustelle, aber gut zu wissen + +Seit #528 (Stufe 2) hört dieselbe Instanz zusätzlich auf einem Socket im +Instanzverzeichnis (`manager.sock`, unter Windows eine Named Pipe). Über ihn +wird der Instanzmanager die Instanz erreichen. Drei Dinge, die dich betreffen +könnten: + +- **Er ist immer da**, auch wenn `webUiEnabled` aus ist. Was du in `Logs/Web.log` + als „Instanz hört auf …" siehst, ist er und nicht die Weboberfläche. +- **Er kennt kein Token.** Seine Grenze steht am Dateisystem. Wer über den Socket + hereinkommt, ist an `TokenGate` vorbei — das ist Absicht und in + `LocalTransport` festgehalten. +- **Für dich ändert sich nichts.** Endpunkte, Übertragungstypen und der + Ereignisstrom sind dieselben; es ist nur eine zweite Tür in denselben Raum. + --- ## 2 — Die API diff --git a/src/ClawdDotNet.Desktop/App.axaml.cs b/src/ClawdDotNet.Desktop/App.axaml.cs index 53b22cc..dbd0750 100644 --- a/src/ClawdDotNet.Desktop/App.axaml.cs +++ b/src/ClawdDotNet.Desktop/App.axaml.cs @@ -21,12 +21,12 @@ public partial class App : Application /// starten laesst. Gesetzt wird er nur von der Anwendung selbst, wenn der /// Instanzdialog einen Wechsel verlangt — deshalb ist er nirgends dokumentiert. /// - private const string InstanceArgument = "--instance"; + private const string InstanceArgument = CommandLine.InstanceArgument; private AppHost? _host; /// Die Weboberflaeche, falls sie eingeschaltet ist. Sonst null. - private WebUiHost? _web; + private InstanceHttpHost? _web; /// Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht. private string? _preselectedInstancePath; @@ -42,7 +42,7 @@ public partial class App : Application // in genau dieser Lücke aussteigen. desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; - _preselectedInstancePath = ReadInstanceArgument(desktop.Args); + _preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args); ApplySavedThemeEarly(); @@ -118,10 +118,13 @@ public partial class App : Application HookErrorReporting(_host); HookLicenseWatch(_host, desktop); - // Nach dem Aufbau und vor dem Fenster: Die Weboberflaeche braucht den fertigen + // Nach dem Aufbau und vor dem Fenster: Die HTTP-Flaeche braucht den fertigen // AppHost, und ein belegter Port darf das Fenster nicht aufhalten — TryStart // gibt in jedem Fehlerfall null zurueck und schreibt den Grund ins Protokoll. - _web = WebUiHost.TryStart(_host, _host.LoggerFactory); + // + // Sie ist mehr als die Weboberflaeche: Immer dabei ist der Socket im + // Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528). + _web = InstanceHttpHost.TryStart(_host, _host.LoggerFactory); var mainViewModel = new MainWindowViewModel(_host); @@ -388,19 +391,6 @@ public partial class App : Application await web.DisposeAsync(); } - private static string? ReadInstanceArgument(IReadOnlyList? args) - { - if (args is null) return null; - - for (var i = 0; i < args.Count - 1; i++) - { - if (string.Equals(args[i], InstanceArgument, StringComparison.OrdinalIgnoreCase)) - return args[i + 1]; - } - - return null; - } - /// /// Instanzauswahl beim Start. /// diff --git a/src/ClawdDotNet.Desktop/CommandLine.cs b/src/ClawdDotNet.Desktop/CommandLine.cs new file mode 100644 index 0000000..32cb68e --- /dev/null +++ b/src/ClawdDotNet.Desktop/CommandLine.cs @@ -0,0 +1,42 @@ +namespace ClawdDotNet.Desktop; + +/// +/// Die Argumente, die vor allem anderen gelesen werden müssen. +/// +/// Eigene Klasse, weil sie zwei Aufrufer hat, die sich nicht kennen dürfen: +/// entscheidet damit, ob überhaupt ein Fenster entsteht, und +/// übernimmt die vorgewählte Instanz in den Startvorgang. Lägen sie in +/// App, müsste Program die Oberfläche anfassen, um zu erfahren, dass keine +/// gebraucht wird. +/// +internal static class CommandLine +{ + public const string InstanceArgument = "--instance"; + + /// Läuft ohne Fenster: nur Aufbau, Socket und Warten (#528, Stufe 2). + public const string HeadlessArgument = "--headless"; + + /// + /// Der Instanzpfad hinter --instance, oder null. + /// + /// Bewusst ohne Prüfung, ob es das Verzeichnis gibt — das entscheidet der + /// Aufrufer, und er entscheidet es unterschiedlich: Mit Fenster erscheint dann der + /// Auswahldialog, ohne Fenster ist es ein Abbruch mit Meldung. + /// + public static string? ReadInstancePath(IReadOnlyList? args) + { + if (args is null) return null; + + for (var i = 0; i < args.Count - 1; i++) + { + if (string.Equals(args[i], InstanceArgument, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + + return null; + } + + public static bool IsHeadless(IReadOnlyList? args) + => args is not null + && args.Any(a => string.Equals(a, HeadlessArgument, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/ClawdDotNet.Desktop/HeadlessRun.cs b/src/ClawdDotNet.Desktop/HeadlessRun.cs new file mode 100644 index 0000000..32b86be --- /dev/null +++ b/src/ClawdDotNet.Desktop/HeadlessRun.cs @@ -0,0 +1,138 @@ +using System.Runtime.InteropServices; +using ClawdDotNet.App; +using ClawdDotNet.App.Services; +using ClawdDotNet.Web; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Desktop; + +/// +/// Eine Instanz ohne Fenster (#528, Stufe 2). +/// +/// Wozu, wenn doch jede Instanz ihr Fenster behält (E-M2). Weil der +/// Instanzprozess damit beweist, dass er keines braucht. Ein Server ohne +/// Bildschirmsitzung, ein systemd-Dienst, ein Prüflauf im Bau — alle drei brauchen genau +/// das, und jede versehentliche Abhängigkeit von der Oberfläche fällt hier sofort auf, +/// statt erst auf der Zielmaschine. +/// +/// Was hier nicht gefragt wird, wird gar nicht gefragt. Ein Dienst ohne +/// Sitzung, der beim Start ein Eingabefenster öffnet, hängt für immer, ohne dass jemand +/// die Frage je zu sehen bekommt. Die Instanz kommt deshalb vom Argument, die Lizenz +/// über , und die Telegram-Anmeldung bleibt +/// aus — Callbacks.TelegramLogin ist null, und das ist hier die Antwort und kein +/// Versäumnis. +/// +/// Unter Windows sieht man davon wenig. Die Programmdatei ist eine +/// WinExe und hat keine Konsole; die Meldungen unten landen dann im Nichts. Was +/// zählt, steht ohnehin im Instanzprotokoll. Der Zielbetrieb ist Linux, und dort ist es +/// eine gewöhnliche Konsolenanwendung. +/// +internal static class HeadlessRun +{ + private const int ExitOk = 0; + private const int ExitNoInstance = 2; + private const int ExitStartFailed = 3; + private const int ExitNoEndpoint = 4; + + public static async Task RunAsync(string[] args) + { + var instancePath = CommandLine.ReadInstancePath(args); + + if (string.IsNullOrWhiteSpace(instancePath) || !Directory.Exists(instancePath)) + { + await Console.Error.WriteLineAsync( + $"Ohne Fenster gibt es keinen Auswahldialog. Erwartet wird " + + $"{CommandLine.InstanceArgument} " + + (instancePath is null ? "." : $" — '{instancePath}' gibt es nicht.")); + + return ExitNoInstance; + } + + var (host, error) = await AppHost.StartAsync(new AppHost.Callbacks + { + SelectInstance = _ => Task.FromResult(instancePath), + + // Fragt nicht, sondern meldet, was zu tun ist. Mit E-M3 wandert die + // Lizenzpruefung ohnehin in den Instanzmanager; bis dahin ist das hier der + // ehrliche Weg: Ein Schluessel wird vorab hinterlegt, nicht erfragt. + License = new NonInteractiveLicensePrompt(Console.Error.WriteLine) + }); + + if (host is null) + { + // Kein Fehlertext heisst Abbruch mit Ansage - die Lizenzabfrage hat ihre + // Meldung schon selbst geschrieben. + if (error is not null) await Console.Error.WriteLineAsync(error); + return ExitStartFailed; + } + + await using (host) + { + var logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Headless"); + + var web = InstanceHttpHost.TryStart(host, host.LoggerFactory); + + if (web is null) + { + // Ohne Socket ist eine kopflose Instanz nicht ansprechbar - also nichts, + // was man laufen lassen sollte. Mit Fenster waere das anders: Dort bleibt + // die Oberflaeche als Zugang. + logger.LogError("Kein Zugang: Die HTTP-Fläche ist nicht hochgekommen."); + return ExitNoEndpoint; + } + + await using (web) + { + Console.WriteLine(web.Endpoint); + logger.LogInformation( + "Instanz {Name} läuft ohne Fenster auf {Endpunkt}", + host.Instance.InstanceName, web.Endpoint); + + await WaitForShutdownAsync(logger); + + logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName); + } + } + + return ExitOk; + } + + /// + /// Wartet, bis jemand Schluss sagt. + /// + /// Drei Wege, weil drei Absender in Frage kommen: SIGTERM von systemd + /// oder vom Instanzmanager, SIGINT vom Terminal, und + /// als letzte Gelegenheit. + /// + /// 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. + /// + 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.Desktop/Program.cs b/src/ClawdDotNet.Desktop/Program.cs index 424035c..9f02465 100644 --- a/src/ClawdDotNet.Desktop/Program.cs +++ b/src/ClawdDotNet.Desktop/Program.cs @@ -15,8 +15,15 @@ internal static class Program /// Kein [STAThread] mehr: Das war eine COM-Anforderung von WinForms. Avalonia /// braucht es nicht, und unter Linux hätte es ohnehin keine Bedeutung. /// - public static int Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + /// + /// Die Verzweigung steht vor und nicht darin: Mit + /// --headless soll gar kein Fenstersystem angefasst werden — auf einem Server + /// ohne Bildschirmsitzung gäbe es keines (#528, Stufe 2). + /// + public static int Main(string[] args) + => CommandLine.IsHeadless(args) + ? HeadlessRun.RunAsync(args).GetAwaiter().GetResult() + : BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); /// /// Auch vom Vorschau-Werkzeug des Editors aufgerufen — deshalb öffentlich und diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index cfe6cad..56ad5de 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -10,7 +10,7 @@ namespace ClawdDotNet.Web.Contracts; // Fehler, die niemandem auffällt, weil alles funktioniert. // 2. Die Kerntypen ändern sich mit dem Kern. Ein neues Feld in TaskItem wäre sonst // still eine API-Änderung, auf die sich jemand verlassen hat. -// 3. Die Grenze soll einen Prozesswechsel überstehen (siehe WebUiHost). Über eine +// 3. Die Grenze soll einen Prozesswechsel überstehen (siehe InstanceHttpHost). Über eine // Prozessgrenze geht nur, was hier steht. // // Namen auf Englisch in camelCase wie im übrigen Code; die Beschreibungen auf Deutsch. diff --git a/src/ClawdDotNet.Web/InstanceHttpHost.cs b/src/ClawdDotNet.Web/InstanceHttpHost.cs new file mode 100644 index 0000000..5709db2 --- /dev/null +++ b/src/ClawdDotNet.Web/InstanceHttpHost.cs @@ -0,0 +1,349 @@ +using ClawdDotNet.App; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Web.Endpoints; +using ClawdDotNet.Web.Security; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Web; + +/// +/// Die HTTP-Fläche einer Instanz, im Prozess der Instanz. +/// +/// Zwei Zugänge auf einem Kestrel. Der eine ist der Socket im +/// Instanzverzeichnis () — über ihn wird der +/// Instanzmanager die Instanz erreichen, und er ist immer da. Der andere ist die +/// Weboberfläche auf einem TCP-Port; die ist abschaltbar und in der Vorbelegung aus. +/// +/// Der Unterschied ist nicht kosmetisch, sondern der Torwächter. Über TCP +/// gilt Token-Pflicht. Über den Socket nicht: Dort steht die Grenze am Dateisystem — wer +/// die Socketdatei öffnen darf, darf ohnehin in das Instanzverzeichnis sehen, in dem +/// alles liegt, was ein Token schützen würde. Welche Verbindung welche ist, entscheidet +/// keine Vermutung über IP-Adressen, sondern eine Marke, die der Socket-Zuhörer selbst +/// an die Verbindung hängt (). +/// +/// Im selben Prozess, und das ist keine Bequemlichkeit. Die +/// AgentEngine hält Chatverlauf und Kontext je Agent im Arbeitsspeicher und +/// serialisiert Läufe über Semaphore je Agent; SqliteStorage serialisiert +/// Schreibvorgänge prozessweit. Ein zweiter Prozess hätte eine zweite Engine und keine +/// gemeinsame Sperre — das Ergebnis wäre keine Verlangsamung, sondern eine ungültige +/// tool_call-Sequenz und HTTP 400 mitten im Lauf. Genau deshalb spricht der +/// Manager über den Socket mit dieser Instanz, statt ihre Daten selbst anzufassen. +/// +public sealed class InstanceHttpHost : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly ILogger _logger; + private readonly string _instancePath; + + private InstanceHttpHost( + WebApplication app, ILogger logger, string instancePath, string endpoint, string? url) + { + _app = app; + _logger = logger; + _instancePath = instancePath; + Endpoint = endpoint; + Url = url; + } + + /// Socket oder Named Pipe — der Weg, den der Instanzmanager nimmt. + public string Endpoint { get; } + + /// + /// Die Adresse der Weboberfläche, oder null: abgeschaltet, falsch eingestellt + /// oder der Port war belegt. + /// + public string? Url { get; } + + /// + /// Fährt die HTTP-Fläche hoch. + /// + /// Gibt in jedem Fehlerfall null zurück und wirft nicht. Eine + /// Instanz, deren Fenster nicht aufgeht, weil ein Webserver keine Adresse bekommen + /// hat, wäre genau dann im Weg, wenn man sie braucht. Was schiefging, steht im + /// Protokoll. + /// + /// Scheitert allein der TCP-Zuhörer — fast immer ein belegter Port, weil eine + /// zweite Instanz läuft —, wird ein zweites Mal ohne ihn gestartet. Der Socket ist + /// der Zugang, auf den es künftig ankommt; ihn wegen der Weboberfläche zu verlieren + /// wäre die falsche Rangfolge. + /// + public static InstanceHttpHost? TryStart(AppHost host, ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("ClawdDotNet.Web"); + + var options = WebUiOptions.TryCreate(host.Instance, out var problem); + + if (options is null && problem is not null) + logger.LogWarning("Weboberfläche nicht gestartet: {Grund}", problem); + + var started = TryStart(host, loggerFactory, logger, options); + + if (started is null && options is not null) + { + // Zweiter Versuch ohne die Weboberflaeche. Der haeufigste Grund ist ein + // belegter Port - eine zweite Instanz, ein vergessener Prozess. Das ist ein + // Grund, ohne Weboberflaeche zu laufen, und keiner, ohne Socket zu laufen. + logger.LogWarning( + "Weboberfläche konnte nicht an {Url} binden — die Instanz läuft ohne sie weiter.", + options.Url); + + started = TryStart(host, loggerFactory, logger, options: null); + } + + return started; + } + + private static InstanceHttpHost? TryStart( + AppHost host, ILoggerFactory loggerFactory, ILogger logger, WebUiOptions? options) + { + var endpoint = InstanceEndpoint.Describe(host.InstancePath); + + try + { + InstanceEndpoint.ClearStale(host.InstancePath); + + var app = Build(host, loggerFactory, options); + + // Nicht abwarten: StartAsync bindet die Zuhoerer und kehrt zurueck; + // blockierend wuerde hier der Faden haengen, auf dem gleich das Fenster + // entsteht. + app.StartAsync().GetAwaiter().GetResult(); + + InstanceEndpoint.Restrict(host.InstancePath); + + logger.LogInformation("Instanz hört auf {Endpunkt}", endpoint); + + if (options is not null) + logger.LogInformation( + "Weboberfläche hört auf {Url} ({Bindung})", + options.DisplayUrl, + options.LoopbackOnly ? "nur lokal" : "alle Adressen"); + + return new InstanceHttpHost( + app, logger, host.InstancePath, endpoint, options?.DisplayUrl); + } + catch (Exception ex) + { + logger.LogWarning(ex, "HTTP-Fläche konnte nicht gestartet werden ({Endpunkt})", endpoint); + return null; + } + } + + private static WebApplication Build( + AppHost host, ILoggerFactory loggerFactory, WebUiOptions? options) + { + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions + { + ContentRootPath = AppContext.BaseDirectory, + EnvironmentName = Environments.Production, + ApplicationName = typeof(InstanceHttpHost).Assembly.GetName().Name + }); + + // Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen. + builder.Logging.ClearProviders(); + builder.Logging.AddProvider(new 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 + // Stylesheet. In einer Viertelstunde Herumklicken standen so mehrere tausend + // Zeilen im Instanzprotokoll, zwischen denen die Meldungen der Agenten + // untergingen. Ab Warnung ist es das, was man wissen will. + builder.Logging.AddFilter("Microsoft", LogLevel.Warning); + builder.Logging.AddFilter("System", LogLevel.Warning); + + // CreateSlimBuilder bringt nur den Socket-Transport mit. Named Pipes muessen + // eigens angemeldet werden, sonst wirft ListenNamedPipe beim Binden. + // + // CurrentUserOnly ist die Vorgabe und steht trotzdem da: Es ist unter Windows + // die ganze Zugangsbeschraenkung des Sockets - das Gegenstueck zu 0600 unter + // Linux. Eine Vorgabe, auf die man sich verlaesst, ohne sie zu nennen, ist eine, + // die beim naechsten Rahmenwechsel unbemerkt kippt. + if (OperatingSystem.IsWindows()) + builder.WebHost.UseNamedPipes(pipes => pipes.CurrentUserOnly = true); + + builder.WebHost.ConfigureKestrel(kestrel => Listen(kestrel, host.InstancePath, options)); + + builder.Services.AddSingleton(host); + + if (options is not null) + { + builder.Services.AddSingleton(options); + builder.Services.AddSingleton(TokenGate.For(options.Token)); + } + + var app = builder.Build(); + + // Reihenfolge ist hier die halbe Miete: + // 1. Fehlerbehandlung ganz aussen, damit sie auch das Folgende einfaengt. + // 2. Torwaechter vor allem, was Daten liefert. + // 3. Statische Dateien - die leere Huelle, bewusst ohne Token. + // 4. Endpunkte. + app.Use(ReportUnhandled(host, loggerFactory)); + app.Use(CheckToken); + + if (options is not null) + MapStaticFiles(app); + + app.MapApi(); + + return app; + } + + /// + /// Die Zuhörer: immer der Socket, auf Wunsch dazu der TCP-Port. + /// + /// Der Socket-Zuhörer hängt jeder Verbindung eine Marke an. Nur daran erkennt + /// später, dass die Anfrage über das Dateisystem hereinkam + /// und kein Token braucht. Die Alternative — aus einer fehlenden IP-Adresse zu + /// schließen, es müsse wohl ein Socket gewesen sein — wäre eine Vermutung an der + /// Stelle, an der ein Torwächter steht. + /// + private static void Listen( + KestrelServerOptions kestrel, string instancePath, WebUiOptions? options) + { + if (OperatingSystem.IsWindows()) + { + kestrel.ListenNamedPipe( + InstanceEndpoint.PipeName(instancePath), + listen => listen.Use(LocalTransport.Mark)); + } + else + { + kestrel.ListenUnixSocket( + InstanceEndpoint.SocketPath(instancePath), + listen => listen.Use(LocalTransport.Mark)); + } + + if (options is null) return; + + var address = options.LoopbackOnly + ? System.Net.IPAddress.Loopback + : System.Net.IPAddress.Any; + + kestrel.Listen(address, options.Port); + } + + /// + /// Fängt ab, was in einem Endpunkt durchrutscht: meldet es an den Fehler-Stream und + /// antwortet mit 500. + /// + /// Dasselbe, was App.axaml.cs für die Oberfläche tut — nur von innen. + /// Ohne das nähme eine Ausnahme in einem Endpunkt im ungünstigen Fall den ganzen + /// Prozess mit, und mit ihm die Agenten. + /// + private static Func ReportUnhandled( + AppHost host, ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("ClawdDotNet.Web"); + + return async (context, next) => + { + try + { + await next(context); + } + catch (OperationCanceledException) + { + // Der Browser hat aufgelegt, oder die Anwendung faehrt herunter. Beides + // ist kein Fehler und schon gar keiner, der ins Deploymentcenter gehoert. + // + // Bewusst ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht + // ApplicationStopping ab, und RequestAborted ist dabei gar nicht gesetzt. + // Mit der Bedingung landete jeder offene Ereignisstrom beim Beenden als + // gemeldeter Fehler im Bugtracker. + } + catch (Exception ex) + { + logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path); + _ = host.Errors.ReportAsync(ex, fatal: false); + + 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 (!TokenGate.NeedsToken(context.Request.Path)) + { + await next(context); + return; + } + + // Ueber den Socket: Das Dateisystem hat bereits entschieden. + if (LocalTransport.Applies(context)) + { + await next(context); + return; + } + + // Kein Torwaechter bedeutet: Es gibt keine Weboberflaeche, und diese Anfrage kam + // trotzdem ueber TCP. Das darf nicht vorkommen - dann hoert gar kein TCP-Zuhoerer + // - und wenn doch, ist Abweisen die richtige Antwort. + var gate = context.RequestServices.GetService(); + + if (gate is null || !gate.Matches(TokenGate.ReadToken(context.Request))) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." }); + return; + } + + await next(context); + } + + /// + /// Liefert die Oberfläche aus wwwroot neben der Programmdatei. + /// + /// Fehlt der Ordner, läuft die API trotzdem — nützlich, solange an der + /// Oberfläche noch gebaut wird, und ehrlicher als ein Absturz beim Start. + /// + 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 + { + // Erst hier herunterfahren, dann der AppHost: Andersherum bediente Kestrel + // noch Anfragen gegen eine bereits geschlossene Datenbank. + await _app.StopAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "HTTP-Fläche ließ sich nicht sauber beenden"); + } + + await _app.DisposeAsync(); + + // Die Socketdatei ueberlebt den Prozess, der sie angelegt hat. Wer sie beim + // geordneten Ende liegen laesst, hinterlaesst dem naechsten Start ein + // EADDRINUSE von einem Prozess, den es nicht mehr gibt. + InstanceEndpoint.ClearStale(_instancePath); + } +} diff --git a/src/ClawdDotNet.Web/Security/LocalTransport.cs b/src/ClawdDotNet.Web/Security/LocalTransport.cs new file mode 100644 index 0000000..fcb37da --- /dev/null +++ b/src/ClawdDotNet.Web/Security/LocalTransport.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http; + +namespace ClawdDotNet.Web.Security; + +/// +/// Die Marke, an der eine Anfrage über den Instanz-Socket zu erkennen ist. +/// +/// Warum eine Marke und keine Prüfung der Adresse. Bei einer Verbindung über +/// Unix-Domain-Socket oder Named Pipe stehen in HttpContext.Connection keine +/// IP-Adressen. Daraus zu schließen „keine IP, also war es der Socket" wäre eine +/// Vermutung — und zwar genau an der Stelle, an der ein Torwächter steht: Wer sich hier +/// irrt, lässt eine TCP-Anfrage ohne Token durch. Der Socket-Zuhörer hängt deshalb selbst +/// jeder Verbindung diese Marke an. Sie entsteht ausschließlich in +/// und kann von außen weder gesetzt noch nachgeahmt werden; wo sie +/// fehlt, gilt Token-Pflicht. +/// +/// Dass der Socket ohne Token auskommt, ist keine Nachlässigkeit: Seine Grenze +/// steht am Dateisystem (0600 beziehungsweise CurrentUserOnly). Wer die Socketdatei +/// öffnen darf, darf ohnehin in das Instanzverzeichnis sehen — und dort liegt alles, was +/// ein Token schützen würde. +/// +internal sealed class LocalTransport +{ + private static readonly LocalTransport Instance = new(); + + private LocalTransport() { } + + /// + /// Verbindungs-Zwischenschicht des Socket-Zuhörers: setzt die Marke, bevor die erste + /// Anfrage entsteht. + /// + /// Die Verbindungsmerkmale liegen unter denen der Anfrage — was hier gesetzt + /// wird, findet später über HttpContext.Features + /// wieder. + /// + public static Func Mark + => next => connection => + { + connection.Features.Set(Instance); + return next(connection); + }; + + /// Ob diese Anfrage über den Instanz-Socket hereinkam. + public static bool Applies(HttpContext context) + => context.Features.Get() is not null; +} diff --git a/src/ClawdDotNet.Web/WebUiHost.cs b/src/ClawdDotNet.Web/WebUiHost.cs deleted file mode 100644 index ff54779..0000000 --- a/src/ClawdDotNet.Web/WebUiHost.cs +++ /dev/null @@ -1,237 +0,0 @@ -using ClawdDotNet.App; -using ClawdDotNet.Web.Endpoints; -using ClawdDotNet.Web.Security; -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.Web; - -/// -/// Die Weboberfläche, im Prozess der Anwendung. -/// -/// Im selben Prozess, und das ist keine Bequemlichkeit. Die -/// AgentEngine hält Chatverlauf und Kontext je Agent im Arbeitsspeicher und -/// serialisiert Läufe über Semaphore je Agent; SqliteStorage serialisiert -/// Schreibvorgänge prozessweit. Ein zweiter Prozess hätte eine zweite Engine und keine -/// gemeinsame Sperre — das Ergebnis wäre keine Verlangsamung, sondern eine ungültige -/// tool_call-Sequenz und HTTP 400 mitten im Lauf. -/// -/// Die Trennung „Kern als Dienst, Oberfläche als Client" steht auf der Roadmap als -/// offene Entscheidung. Diese Klasse trifft sie nicht — aber die HTTP-Fläche darunter -/// ist so geschnitten, dass sie einen Prozesswechsel überstünde: keine Objektidentität -/// über Aufrufe hinweg, alles über Kennungen und eigene Übertragungstypen. -/// -public sealed class WebUiHost : IAsyncDisposable -{ - private readonly WebApplication _app; - private readonly ILogger _logger; - - private WebUiHost(WebApplication app, ILogger logger, string url) - { - _app = app; - _logger = logger; - Url = url; - } - - /// Die Adresse, unter der die Oberfläche hört. - public string Url { get; } - - /// - /// Fährt die Weboberfläche hoch, sofern sie eingeschaltet und richtig eingestellt - /// ist. - /// - /// Gibt in jedem Fehlerfall null zurück und wirft nicht. Eine - /// Anwendung, deren Fenster nicht aufgeht, weil ein Webserver keine Adresse bekommen - /// hat, wäre genau dann im Weg, wenn man sie braucht. Was schiefging, steht im - /// Protokoll. - /// - public static WebUiHost? TryStart(AppHost host, ILoggerFactory loggerFactory) - { - var logger = loggerFactory.CreateLogger("ClawdDotNet.Web"); - - var options = WebUiOptions.TryCreate(host.Instance, out var problem); - - if (options is null) - { - if (problem is not null) - logger.LogWarning("Weboberfläche nicht gestartet: {Grund}", problem); - - return null; - } - - try - { - var app = Build(host, loggerFactory, options); - - // Nicht abwarten: StartAsync bindet den Port und kehrt zurueck; blockierend - // wuerde hier der Faden haengen, auf dem gleich das Fenster entsteht. - app.StartAsync().GetAwaiter().GetResult(); - - logger.LogInformation( - "Weboberfläche hört auf {Url} ({Bindung})", - options.DisplayUrl, - options.LoopbackOnly ? "nur lokal" : "alle Adressen"); - - return new WebUiHost(app, logger, options.DisplayUrl); - } - catch (Exception ex) - { - // Der haeufigste Fall ist ein belegter Port - eine zweite Instanz, ein - // vergessener Prozess. Das ist ein Grund, ohne Weboberflaeche zu laufen, und - // keiner, die Anwendung nicht zu starten. - logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", options.Url); - return null; - } - } - - private static WebApplication Build( - AppHost host, ILoggerFactory loggerFactory, WebUiOptions options) - { - var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions - { - ContentRootPath = AppContext.BaseDirectory, - EnvironmentName = Environments.Production, - ApplicationName = typeof(WebUiHost).Assembly.GetName().Name - }); - - // Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen. - builder.Logging.ClearProviders(); - builder.Logging.AddProvider(new 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 - // Stylesheet. In einer Viertelstunde Herumklicken standen so mehrere tausend - // Zeilen im Instanzprotokoll, zwischen denen die Meldungen der Agenten - // untergingen. Ab Warnung ist es das, was man wissen will. - builder.Logging.AddFilter("Microsoft", LogLevel.Warning); - builder.Logging.AddFilter("System", LogLevel.Warning); - - builder.WebHost.UseUrls(options.Url); - - builder.Services.AddSingleton(host); - builder.Services.AddSingleton(options); - builder.Services.AddSingleton(TokenGate.For(options.Token)); - - var app = builder.Build(); - - // Reihenfolge ist hier die halbe Miete: - // 1. Fehlerbehandlung ganz aussen, damit sie auch das Folgende einfaengt. - // 2. Torwaechter vor allem, was Daten liefert. - // 3. Statische Dateien - die leere Huelle, bewusst ohne Token. - // 4. Endpunkte. - app.Use(ReportUnhandled(host, loggerFactory)); - app.Use(CheckToken); - - MapStaticFiles(app); - app.MapApi(); - - return app; - } - - /// - /// Fängt ab, was in einem Endpunkt durchrutscht: meldet es an den Fehler-Stream und - /// antwortet mit 500. - /// - /// Dasselbe, was App.axaml.cs für die Oberfläche tut — nur von innen. - /// Ohne das nähme eine Ausnahme in einem Endpunkt im ungünstigen Fall den ganzen - /// Prozess mit, und mit ihm die Agenten. - /// - private static Func ReportUnhandled( - AppHost host, ILoggerFactory loggerFactory) - { - var logger = loggerFactory.CreateLogger("ClawdDotNet.Web"); - - return async (context, next) => - { - try - { - await next(context); - } - catch (OperationCanceledException) - { - // Der Browser hat aufgelegt, oder die Anwendung faehrt herunter. Beides - // ist kein Fehler und schon gar keiner, der ins Deploymentcenter gehoert. - // - // Bewusst ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht - // ApplicationStopping ab, und RequestAborted ist dabei gar nicht gesetzt. - // Mit der Bedingung landete jeder offene Ereignisstrom beim Beenden als - // gemeldeter Fehler im Bugtracker. - } - catch (Exception ex) - { - logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path); - _ = host.Errors.ReportAsync(ex, fatal: false); - - 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 (!TokenGate.NeedsToken(context.Request.Path)) - { - await next(context); - return; - } - - var gate = context.RequestServices.GetRequiredService(); - - if (!gate.Matches(TokenGate.ReadToken(context.Request))) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." }); - return; - } - - await next(context); - } - - /// - /// Liefert die Oberfläche aus wwwroot neben der Programmdatei. - /// - /// Fehlt der Ordner, läuft die API trotzdem — nützlich, solange an der - /// Oberfläche noch gebaut wird, und ehrlicher als ein Absturz beim Start. - /// - 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 - { - // Erst hier herunterfahren, dann der AppHost: Andersherum bediente Kestrel - // noch Anfragen gegen eine bereits geschlossene Datenbank. - await _app.StopAsync(TimeSpan.FromSeconds(5)); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden"); - } - - await _app.DisposeAsync(); - } -} diff --git a/src/ClawdDotNet.Web/WebUiOptions.cs b/src/ClawdDotNet.Web/WebUiOptions.cs index 008fabd..38d73f4 100644 --- a/src/ClawdDotNet.Web/WebUiOptions.cs +++ b/src/ClawdDotNet.Web/WebUiOptions.cs @@ -9,8 +9,14 @@ namespace ClawdDotNet.Web; /// Entscheidungen: Der Port ist eine Zahl, die Bindung eine von zweien, und ein Token, /// das nicht taugt, ist gar keins. Was hier nicht durchkommt, kommt gar nicht erst an /// den Kestrel. +/// +/// Nur der TCP-Zugang steht hier. Der Socket, über den der Instanzmanager +/// die Instanz erreicht, braucht keine Einstellung: Seine Adresse folgt aus dem +/// Instanzverzeichnis, seine Grenze aus den Dateirechten. null aus +/// heißt deshalb „keine Weboberfläche", nicht „keine +/// Instanz". /// -public sealed record WebUiOptions(string Url, string Token, bool LoopbackOnly) +public sealed record WebUiOptions(string Url, int Port, string Token, bool LoopbackOnly) { /// Kürzer als das hier darf ein Token nicht sein. public const int MinimumTokenLength = 20; @@ -64,7 +70,7 @@ public sealed record WebUiOptions(string Url, string Token, bool LoopbackOnly) var host = loopbackOnly ? "127.0.0.1" : "0.0.0.0"; - return new WebUiOptions($"http://{host}:{port}", token, loopbackOnly); + return new WebUiOptions($"http://{host}:{port}", port, token, loopbackOnly); } /// diff --git a/tests/ClawdDotNet.Web.Tests/Infrastructure/PlatformFacts.cs b/tests/ClawdDotNet.Web.Tests/Infrastructure/PlatformFacts.cs new file mode 100644 index 0000000..7755c2a --- /dev/null +++ b/tests/ClawdDotNet.Web.Tests/Infrastructure/PlatformFacts.cs @@ -0,0 +1,26 @@ +using ClawdDotNet.Core.Instances; + +namespace ClawdDotNet.Web.Tests.Infrastructure; + +/// +/// Ein Test, der nur dort etwas aussagt, wo der Instanz-Socket eine Datei ist. +/// +/// Unter Linux ist er ein Unix-Domain-Socket im Instanzverzeichnis und damit ein +/// Eintrag im Dateisystem — er überlebt den Prozess, er hat Rechte, er kann liegen +/// bleiben. Unter Windows ist er eine Named Pipe: kein Eintrag, keine Leiche, nichts +/// aufzuräumen. +/// +/// Solche Tests unter Windows mitlaufen zu lassen hieße, sie grün zu sehen, ohne +/// dass irgendetwas geprüft wurde. Deshalb ein Übersprung mit Begründung statt eines +/// stillen return: Im Bericht ist dann sichtbar, dass hier etwas nicht +/// geprüft wurde. Dasselbe Muster wie LinuxFactAttribute in den Kern-Tests. +/// +public sealed class SocketFileFactAttribute : FactAttribute +{ + public SocketFileFactAttribute() + { + if (InstanceEndpoint.UsesNamedPipe) + Skip = "Nur dort aussagekräftig, wo der Socket eine Datei ist — " + + "unter Windows ist er eine Named Pipe."; + } +} diff --git a/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs b/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs new file mode 100644 index 0000000..7411834 --- /dev/null +++ b/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs @@ -0,0 +1,189 @@ +using ClawdDotNet.App; +using ClawdDotNet.App.Services; +using ClawdDotNet.App.Settings; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Web.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Web.Tests; + +/// +/// Der Socket im Instanzverzeichnis (#528, Stufe 2 / E-M1). +/// +/// Über ihn wird der Instanzmanager die Instanz erreichen. Drei Zusagen hält +/// dieser Test fest: Er ist immer da, auch ohne Weboberfläche. Er braucht +/// kein Token — seine Grenze steht am Dateisystem. Und der TCP-Port braucht +/// weiterhin eins, denn dort steht keine. +/// +/// Der letzte Punkt ist der, an dem eine Verwechslung teuer wäre: Wer die +/// Unterscheidung aus fehlenden IP-Adressen erraten wollte, ließe im Zweifel eine +/// TCP-Anfrage ohne Token durch. Sie kommt deshalb aus einer Marke, die der +/// Socket-Zuhörer selbst setzt. +/// +public sealed class InstanceSocketTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "sock-" + Guid.NewGuid().ToString("N")[..8]); + + private const string Token = "ein-hinreichend-langes-testtoken"; + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + private AppHost NeuerHost(bool mitWeboberflaeche, int port = 0) + { + var instancesDir = Path.Combine(_root, "Instances"); + var instancePath = Path.Combine(instancesDir, "Test"); + Directory.CreateDirectory(instancePath); + + return new AppHost + { + Settings = new SettingsManager(_root), + Directories = new InstanceDirectoryManager(instancesDir), + Instance = new InstanceConfig + { + InstanceName = "Test", + WorkingDirectory = instancePath, + WebUiEnabled = mitWeboberflaeche, + WebUiBinding = "loopback", + WebUiToken = mitWeboberflaeche ? Token : "", + WebServerPort = port + }, + InstancePath = instancePath, + LogDirectory = Path.Combine(instancePath, "Logs"), + LoggerFactory = NullLoggerFactory.Instance, + Tools = new ToolRegistry() + }; + } + + private static int FreierPort() + { + using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + probe.Start(); + var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + // ─── Der Socket trägt ─── + + [Fact] + public async Task Der_Socket_antwortet_auch_ohne_Weboberflaeche() + { + // Die Vorbelegung: webUiEnabled ist aus. Frueher lief dann gar nichts, und die + // Instanz war von aussen nicht ansprechbar. + await using var host = NeuerHost(mitWeboberflaeche: false); + await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + + web.ShouldNotBeNull(); + web!.Url.ShouldBeNull(); + + using var client = InstanceEndpoint.CreateClient(host.InstancePath); + var antwort = await client.GetAsync("/api/info"); + + antwort.EnsureSuccessStatusCode(); + (await antwort.Content.ReadAsStringAsync()).ShouldContain("Test"); + } + + [Fact] + public async Task Ueber_den_Socket_braucht_es_kein_Token() + { + await using var host = NeuerHost(mitWeboberflaeche: true, port: FreierPort()); + await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + + web.ShouldNotBeNull(); + + // Kein Authorization-Kopf, mit Absicht. + using var client = InstanceEndpoint.CreateClient(host.InstancePath); + (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); + } + + // ─── Der Port bleibt bewacht ─── + + [Fact] + public async Task Ueber_TCP_gilt_die_Token_Pflicht_weiter() + { + var port = FreierPort(); + + await using var host = NeuerHost(mitWeboberflaeche: true, port: port); + await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + + web.ShouldNotBeNull(); + web!.Url.ShouldNotBeNull(); + + using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; + + (await client.GetAsync("/api/info")).StatusCode + .ShouldBe(System.Net.HttpStatusCode.Unauthorized); + + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); + (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); + } + + // ─── Adressen ─── + + [Fact] + public void Zwei_Instanzen_bekommen_zwei_Adressen() + { + var a = Path.Combine(_root, "Instances", "Eins"); + var b = Path.Combine(_root, "Instances", "Zwei"); + + InstanceEndpoint.Describe(a).ShouldNotBe(InstanceEndpoint.Describe(b)); + InstanceEndpoint.PipeName(a).ShouldNotBe(InstanceEndpoint.PipeName(b)); + } + + [Fact] + public void Dieselbe_Instanz_bekommt_dieselbe_Adresse() + { + // Der Manager rechnet die Adresse aus dem Pfad aus, den er selbst kennt — sie + // muss also aus jeder Schreibweise desselben Verzeichnisses gleich herauskommen. + var a = Path.Combine(_root, "Instances", "Test"); + var b = Path.Combine(_root, "Instances", "Unterwegs", "..", "Test"); + + InstanceEndpoint.PipeName(a).ShouldBe(InstanceEndpoint.PipeName(b)); + InstanceEndpoint.SocketPath(a).ShouldBe(InstanceEndpoint.SocketPath(b)); + } + + // ─── Aufräumen ─── + + /// + /// Eine liegengebliebene Socketdatei darf den nächsten Start nicht verhindern. + /// + /// Ein Unix-Domain-Socket überlebt den Prozess, der ihn angelegt hat. Nach + /// einem harten Ende — genau dem Fall aus #529 — bekäme der nächste Start + /// EADDRINUSE von einem Prozess, den es nicht mehr gibt. + /// + [SocketFileFact] + public async Task Eine_liegengebliebene_Socketdatei_haelt_den_Start_nicht_auf() + { + await using var host = NeuerHost(mitWeboberflaeche: false); + + var leiche = InstanceEndpoint.SocketPath(host.InstancePath); + File.WriteAllText(leiche, "Rest eines abgestuerzten Prozesses"); + + await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + web.ShouldNotBeNull(); + + using var client = InstanceEndpoint.CreateClient(host.InstancePath); + (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); + } + + [SocketFileFact] + public async Task Beim_Beenden_bleibt_keine_Socketdatei_liegen() + { + await using var host = NeuerHost(mitWeboberflaeche: false); + + var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + web.ShouldNotBeNull(); + + await web!.DisposeAsync(); + + File.Exists(InstanceEndpoint.SocketPath(host.InstancePath)).ShouldBeFalse(); + } +} diff --git a/tests/ClawdDotNet.Web.Tests/ShutdownTests.cs b/tests/ClawdDotNet.Web.Tests/ShutdownTests.cs index 385cab1..07c976c 100644 --- a/tests/ClawdDotNet.Web.Tests/ShutdownTests.cs +++ b/tests/ClawdDotNet.Web.Tests/ShutdownTests.cs @@ -3,6 +3,7 @@ using ClawdDotNet.App; using ClawdDotNet.App.Services; using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; @@ -80,7 +81,7 @@ public sealed class ShutdownTests : IDisposable var port = FreierPort(); await using var host = NeuerHost(port); - var web = WebUiHost.TryStart(host, NullLoggerFactory.Instance); + var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; @@ -113,7 +114,7 @@ public sealed class ShutdownTests : IDisposable var port = FreierPort(); await using var host = NeuerHost(port); - var web = WebUiHost.TryStart(host, NullLoggerFactory.Instance); + var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); var uhr = Stopwatch.StartNew(); @@ -123,19 +124,34 @@ public sealed class ShutdownTests : IDisposable uhr.Elapsed.ShouldBeLessThan(Grenze); } + /// + /// Ein belegter Port kostet die Weboberfläche, nicht die Instanz. + /// + /// Vorher gab TryStart hier null zurück, und damit war die + /// gesamte HTTP-Fläche weg. Seit der Socket dazugehört, ist das die falsche + /// Rangfolge: Über ihn wird der Instanzmanager die Instanz erreichen, und ihn wegen + /// eines belegten Ports zu verlieren hieße, die Instanz unerreichbar zu machen, weil + /// eine zweite schon läuft — also genau in dem Fall, für den es den Manager + /// gibt. + /// [Fact] - public void Ein_belegter_Port_haelt_die_Anwendung_nicht_auf() + public async Task Ein_belegter_Port_kostet_nur_die_Weboberflaeche() { var port = FreierPort(); using var blockierer = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port); blockierer.Start(); - var host = NeuerHost(port); + await using var host = NeuerHost(port); - // Kein Wurf, sondern null: Ein Fenster, das nicht aufgeht, weil ein Webserver - // keine Adresse bekommt, waere genau dann im Weg, wenn man es braucht. - WebUiHost.TryStart(host, NullLoggerFactory.Instance).ShouldBeNull(); + await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + + web.ShouldNotBeNull(); + web!.Url.ShouldBeNull("Die Weboberfläche konnte nicht binden — das gehört gesagt."); + + // Der Socket traegt trotzdem. + using var client = InstanceEndpoint.CreateClient(host.InstancePath); + (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); } public void Dispose()