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