Instanzmanager Stufe 2: Kestrel auf dem Socket, Instanz kopflos (#528)
Eine Instanz ist jetzt ohne Port ansprechbar. InstanceEndpoint liefert die Adresse: unter Linux <Instanz>/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 <Pfad>. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using ClawdDotNet.Core.Instances;
|
||||
|
||||
namespace ClawdDotNet.Web.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Ein Test, der nur dort etwas aussagt, wo der Instanz-Socket eine Datei ist.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
///
|
||||
/// <para>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 <c>return</c>: Im Bericht ist dann sichtbar, dass hier etwas <em>nicht</em>
|
||||
/// geprüft wurde. Dasselbe Muster wie <c>LinuxFactAttribute</c> in den Kern-Tests.</para>
|
||||
/// </summary>
|
||||
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.";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Der Socket im Instanzverzeichnis (#528, Stufe 2 / E-M1).
|
||||
///
|
||||
/// <para>Über ihn wird der Instanzmanager die Instanz erreichen. Drei Zusagen hält
|
||||
/// dieser Test fest: Er ist <b>immer</b> da, auch ohne Weboberfläche. Er braucht
|
||||
/// <b>kein Token</b> — seine Grenze steht am Dateisystem. Und der TCP-Port braucht
|
||||
/// weiterhin eins, denn dort steht keine.</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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 ───
|
||||
|
||||
/// <summary>
|
||||
/// Eine liegengebliebene Socketdatei darf den nächsten Start nicht verhindern.
|
||||
///
|
||||
/// <para>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
|
||||
/// <c>EADDRINUSE</c> von einem Prozess, den es nicht mehr gibt.</para>
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein belegter Port kostet die Weboberfläche, nicht die Instanz.
|
||||
///
|
||||
/// <para>Vorher gab <c>TryStart</c> hier <c>null</c> 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.</para>
|
||||
/// </summary>
|
||||
[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()
|
||||
|
||||
Reference in New Issue
Block a user