Der Managerprozess. ClawdDotNet.Instanzmanager ist eine Konsolenanwendung: Lizenz pruefen, uebernehmen was laeuft, Weboberflaeche aufhaengen, warten. Beim Herunterfahren beendet er keine Instanz. Zwei neue Projekte. ClawdDotNet.Manager.Web traegt die Weboberflaeche - ohne jeden Verweis auf ClawdDotNet.Web, denn dort haengt App und daran sechzehn Werkzeugprojekte. ClawdDotNet.ManagerApp ist der Einstiegspunkt. wwwroot ist aus der Instanz in den Manager gezogen. DER SCHNITT DER HTTP-FLAECHE: Unter /api/verwaltung steht, was den Manager angeht - auflisten, anlegen, starten, anhalten. Alles andere unter /api geht unveraendert an eine Instanz. Er leitet weiter, er implementiert nicht. Welche Instanz gemeint ist, sagt die Kopfzeile X-Instanz; laeuft genau eine, ist sie gemeint, und laufen mehrere, wird nachgefragt statt geraten - eine Anfrage an die falsche Instanz ist schlimmer als eine abgelehnte. Der zusammengefuehrte Ereignisstrom haelt je laufender Instanz eine Zuleitung und schreibt an jedes Ereignis, von wem es kommt. Ergaenzt statt verpackt: Ein Umschlag waere einfacher gewesen, haette aber jede Ansicht der Weboberflaeche zum Umschreiben gezwungen. ABNAHME gegen laufende Prozesse: 401 ohne Token, Oberflaeche ohne Token 200, Instanz ueber den Manager gestartet und angehalten, /api/info und /api/status unverfaelscht durchgereicht, /events/logs mit "instanz":"Instance-Test" an jeder Zeile. Manager abgeschossen: Instanz laeuft weiter. Manager neu gestartet: "1 Instanzen bekannt, 1 laufen bereits" - uebernommen. E-M6 damit in beide Richtungen belegt. DREI DEFEKTE, die erst der Lauf gezeigt hat: 1. Der Manager hing an einer Konsolenabfrage nach dem Lizenzschluessel. Ein Test auf Console.IsInputRedirected reicht dafuer NICHT: Er beantwortet "ist die Eingabe umgeleitet", nicht "kann jemand antworten" - ein Prozess ganz ohne Konsole, der gefaehrlichste Fall, ist nicht umgeleitet. Der Manager fragt jetzt gar nicht. 2. Eine unlesbare manager.json wurde stillschweigend durch Vorgaben ersetzt: falsches Instanzverzeichnis, anderer Port, Lizenzfrage - und nichts sagte, woran es lag. LoadProblem meldet den Grund jetzt. 3. Der Instanzprozess erbte CLAWD_CONFIG_DIR vom Manager und legte sich in dessen Konfigurationsverzeichnis eine eigene Settings.json an. Eine Umgebungsvariable vererbt sich, ein Argument nicht - der Manager nimmt sein Verzeichnis jetzt ueber --config. SharedLoggerProvider ist nach Core gezogen: Es gibt jetzt zwei Webhosts, die dasselbe brauchen, und sie duerfen einander nicht kennen. NOCH OFFEN in dieser Stufe: Die Weboberflaeche kennt noch keine Instanzen - Instanzauswahl, X-Instanz und die Verwaltungsansicht sind Arbeit an wwwroot. Und webServerPort steht noch in der Instanz. Build ohne Warnungen, 775 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
82 lines
3.9 KiB
C#
82 lines
3.9 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
namespace ClawdDotNet.Manager.Web;
|
|
|
|
/// <summary>
|
|
/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
|
|
///
|
|
/// <para><b>Warum der Manager einen eigenen hat und nicht den der Instanz.</b> Die Instanz
|
|
/// hört seit Stufe 2 nur noch auf ihrem Socket, und dort ist die Grenze das Dateisystem —
|
|
/// ihr Torwächter kennt deshalb eine Ausnahme für lokale Verbindungen. Hier gibt es die
|
|
/// nicht: Alles unter <c>/api</c> und <c>/events</c> kommt über TCP herein, und alles davon
|
|
/// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt
|
|
/// werden kann.</para>
|
|
///
|
|
/// <para><b>Was ohne Token ausgeliefert wird, ist die leere Hülle</b> — HTML, CSS und
|
|
/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert:
|
|
/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die
|
|
/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte.
|
|
/// In den Dateien steht nichts, was jemand nicht wissen dürfte.</para>
|
|
///
|
|
/// <para><b>Warum der Vergleich in fester Zeit läuft:</b> Ein gewöhnlicher
|
|
/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst,
|
|
/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der
|
|
/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand.</para>
|
|
/// </summary>
|
|
public sealed class AccessToken(byte[] expected)
|
|
{
|
|
/// <summary>Kürzer als das hier darf ein Token nicht sein.</summary>
|
|
public const int MinimumLength = 20;
|
|
|
|
public static AccessToken For(string token)
|
|
=> new(Encoding.UTF8.GetBytes(token));
|
|
|
|
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
|
|
public static bool NeedsToken(PathString path)
|
|
=> path.StartsWithSegments("/api") || path.StartsWithSegments("/events");
|
|
|
|
/// <summary>
|
|
/// Holt das Token aus der Anfrage.
|
|
///
|
|
/// <para><c>Authorization: Bearer …</c> ist der Weg. Ein Cookie wäre bequemer und
|
|
/// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit,
|
|
/// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die
|
|
/// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier
|
|
/// keine eigene Baustelle.</para>
|
|
///
|
|
/// <para>Die Ausnahme ist der Ereignisstrom: <c>EventSource</c> im Browser kann keine
|
|
/// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der
|
|
/// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit
|
|
/// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in
|
|
/// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse Proxy
|
|
/// davor und entscheidet dort, was protokolliert wird.</para>
|
|
/// </summary>
|
|
public static string? Read(HttpRequest request)
|
|
{
|
|
var header = request.Headers.Authorization.ToString();
|
|
|
|
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
|
return header["Bearer ".Length..].Trim();
|
|
|
|
if (request.Path.StartsWithSegments("/events")
|
|
&& request.Query.TryGetValue("token", out var fromQuery))
|
|
return fromQuery.ToString();
|
|
|
|
return null;
|
|
}
|
|
|
|
public bool Matches(string? candidate)
|
|
{
|
|
if (string.IsNullOrEmpty(candidate)) return false;
|
|
|
|
// FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass
|
|
// dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis,
|
|
// sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit
|
|
// verglichen.
|
|
return CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(candidate), expected);
|
|
}
|
|
}
|