Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs
T
RichardandClaude Opus 5 fb185ca84d Sicherungen mit dem Lizenzschluessel verschluesselt, optional per FTP (#530)
Eine Sicherung des Managers ist jetzt als Ganzes mit dem Lizenzschluessel
verschluesselt. Beim Zurueckspielen wird der eigene Schluessel probiert; passt er
nicht, fragt die Oberflaeche nach dem Lizenzschluessel der Quellinstallation.

Der Lizenzschluessel ist der eine Wert, den jede Installation ohnehin hat, und er
liegt im Manager, nicht neben den Archiven. Damit faellt der Grund weg, aus dem die
taegliche Sicherung bisher keine Zugangsdaten enthielt: Eine Passphrase haette
gespeichert werden muessen, und neben den Sicherungen waere sie wirkungslos
gewesen. Jetzt reisen die Zugangsdaten mit, und eine Sicherung uebersteht den
Maschinenwechsel ohne Passphrase.

LicenseBackupCipher: eigener Behaelter .clawdbak. Kopf mit Schluesselprobe, das
Manifest als eigener Abschnitt, das ZIP in 1-MiB-Stuecken mit AES-256-GCM und
Endmarke. PBKDF2 aus Lizenzschluessel und festem Salt, der Schluessel normiert.
Kein RAR (braucht das lizenzpflichtige Herstellerprogramm), kein ZIP mit Kennwort
(kann .NET nicht schreiben, ZipCrypto ist gebrochen, und mit abgeleitetem
Schluessel braechte 7-Zip-Vertraeglichkeit nichts).

Zurueckspielen: Die Schluesselprobe laeuft VOR dem Anhalten der Instanz. Passt
der Schluessel nicht, antwortet der Endpunkt mit 422 und dem Code fremde_lizenz,
und ein Fenster verlangt den Schluessel der Quelle.

ManagerBackups: Gefuehrte Instanzen sichern sich nicht mehr selbst, weil nur der
Manager den Schluessel hat; er liest ihre Einstellungen aus InstanceSettings.json.
Einmal am Tag auch ueber einen Neustart des Managers hinweg. Ohne Lizenzschluessel
wird nichts gesichert, auch nicht ersatzweise unverschluesselt.

FTP (FluentFTP, schon im Loesungsbaum): TLS wo angeboten, requireTls erzwingt es;
Upload unter .part-Namen und danach umbenannt; Aufraeumen auf dem Server je
Instanz; ein gescheiterter Upload laesst die lokale Sicherung stehen. Das
Kennwort liegt verschluesselt in manager.json und geht nie an die Oberflaeche.

Welche Datei zu welcher Instanz gehoert, entscheidet ein Zeitstempel nach dem
Namen und nicht das Praefix - Test und Test_2 werden nicht mehr verwechselt.

Grenze, bewusst und dokumentiert (Konzept 9.10): Wer Lizenzschluessel und Archiv
hat, hat die Zugangsdaten. Der Schluessel ist dem Hersteller bekannt und gilt fuer
alle Instanzen einer Installation.

Nicht ueber die Oberflaeche abgenommen: Fenster fuer die fremde Lizenz und FTP
gegen einen echten Server stehen als Block H in #537.

860 Tests gruen, 0 Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:03:25 +02:00

231 lines
8.3 KiB
C#

using ClawdDotNet.Core.Logging;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager.Web;
/// <summary>
/// Die eine Weboberfläche für alle Instanzen (#528, E-M5).
///
/// <para><b>Eine Adresse, ein Zugang, alle Instanzen.</b> Der Manager besitzt Port, Zugang
/// und Rahmen; Anfragen zu Chat, Agenten, Aufgaben und Freigaben reicht er über den Socket
/// an die betroffene Instanz weiter. Vorher hatte jede Instanz ihren eigenen Port, und
/// zwei Instanzen stritten sich stillschweigend um die Vorbelegung 7001.</para>
///
/// <para><b>Er leitet weiter, er implementiert nicht.</b> Unter <c>/api/verwaltung</c>
/// steht, was den Manager selbst angeht: welche Instanzen es gibt, anlegen, starten,
/// anhalten. Alles andere unter <c>/api</c> geht unverändert an eine Instanz. Wer hier
/// einen Endpunkt ergänzt, der etwas über Agenten oder Aufgaben weiß, hat Fachlogik in
/// den Manager gelegt.</para>
///
/// <para><b>Zwei Dinge, die er nicht kann und nicht können soll:</b> Er fasst keine
/// <c>state.db</c> an — die gehört genau einem Prozess, und prozessübergreifend gilt die
/// Schreibsperre von <c>SqliteStorage</c> nicht. Und er führt keine Werkzeuge aus;
/// Telegram, Rocket.Chat, Mail und FTP laufen ausschließlich in den Instanzen.</para>
/// </summary>
public sealed class ManagerWebHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly ILogger _logger;
private readonly InstanceForwarding _forwarding;
private ManagerWebHost(
WebApplication app, ILogger logger, InstanceForwarding forwarding, string url)
{
_app = app;
_logger = logger;
_forwarding = forwarding;
Url = url;
}
/// <summary>Die Adresse, unter der die Oberfläche hört.</summary>
public string Url { get; }
/// <summary>
/// Fährt die Weboberfläche hoch.
///
/// <para><b>Gibt in jedem Fehlerfall <c>null</c> zurück und wirft nicht.</b> Ein
/// Manager, der nicht anläuft, weil ein Port belegt ist, wäre genau dann im Weg, wenn
/// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
/// steht im Protokoll.</para>
/// </summary>
public static ManagerWebHost? TryStart(ManagerCore manager, ManagerBackups backups, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
var settings = manager.Settings;
var token = settings.WebUiToken?.Trim() ?? "";
if (token.Length < AccessToken.MinimumLength)
{
// Keine halben Sachen: Ein Netzwerkdienst, der Agenten mit Datei-, Mail- und
// FTP-Zugriff steuert, ist nichts, was mit einem schwachen oder gar keinem
// Token offen stehen darf.
logger.LogWarning(
"Weboberfläche nicht gestartet: Es fehlt ein Zugangstoken von mindestens "
+ "{Mindestlänge} Zeichen (webUiToken in manager.json).",
AccessToken.MinimumLength);
return null;
}
var loopback = !string.Equals(settings.WebUiBinding, "any", StringComparison.OrdinalIgnoreCase);
var host = loopback ? "127.0.0.1" : "0.0.0.0";
var url = $"http://{host}:{settings.WebUiPort}";
var forwarding = new InstanceForwarding(logger);
try
{
var app = Build(manager, backups, loggerFactory, url, token, forwarding);
app.StartAsync().GetAwaiter().GetResult();
var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName);
logger.LogInformation(
"Weboberfläche hört auf {Url} ({Bindung})",
anzeige, loopback ? "nur lokal" : "alle Adressen");
return new ManagerWebHost(app, logger, forwarding, anzeige);
}
catch (Exception ex)
{
forwarding.Dispose();
logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", url);
return null;
}
}
private static WebApplication Build(
ManagerCore manager,
ManagerBackups backups,
ILoggerFactory loggerFactory,
string url,
string token,
InstanceForwarding forwarding)
{
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
ContentRootPath = AppContext.BaseDirectory,
EnvironmentName = Environments.Production,
ApplicationName = typeof(ManagerWebHost).Assembly.GetName().Name
});
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
// ASP.NET protokolliert auf Information jede einzelne Anfrage - auch jede
// Schriftart und jedes Stylesheet. Ab Warnung ist es das, was man wissen will.
builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning);
builder.WebHost.UseUrls(url);
builder.Services.AddSingleton(manager);
builder.Services.AddSingleton(manager.Instances);
builder.Services.AddSingleton(backups);
builder.Services.AddSingleton(AccessToken.For(token));
builder.Services.AddSingleton(forwarding);
var app = builder.Build();
app.Use(ReportUnhandled(loggerFactory));
app.Use(CheckToken);
MapStaticFiles(app);
app.MapVerwaltung();
app.MapEvents();
app.MapForwarding();
return app;
}
private static Func<HttpContext, RequestDelegate, Task> ReportUnhandled(
ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
return async (context, next) =>
{
try
{
await next(context);
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt, oder der Manager faehrt herunter. Bewusst
// ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht
// ApplicationStopping ab, und RequestAborted ist dabei nicht gesetzt.
}
catch (Exception ex)
{
logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path);
if (context.Response.HasStarted) return;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { fehler = "Interner Fehler." });
}
};
}
private static async Task CheckToken(HttpContext context, RequestDelegate next)
{
if (!AccessToken.NeedsToken(context.Request.Path))
{
await next(context);
return;
}
var gate = context.RequestServices.GetRequiredService<AccessToken>();
if (!gate.Matches(AccessToken.Read(context.Request)))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." });
return;
}
await next(context);
}
private static void MapStaticFiles(WebApplication app)
{
var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(webRoot))
{
app.Logger.LogWarning(
"Kein wwwroot unter {Pfad} — es läuft nur die API.", webRoot);
return;
}
var files = new PhysicalFileProvider(webRoot);
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
}
public async ValueTask DisposeAsync()
{
try
{
await _app.StopAsync(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden");
}
_forwarding.Dispose();
await _app.DisposeAsync();
}
}