using System.IO; using System.Text.Json; using ClawdDotNet.Core.Backup; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Instances; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Manager.Web; /// Eine Instanz, wie die Weboberfläche sie sieht. /// /// Ob der Manager sie am Laufen hält. Steht in der Instanzdatei und wird dort geändert — /// der Manager liest den Schalter nur. Sie zu schreiben, während der Instanzprozess läuft, /// wären zwei Schreiber auf einer Datei. /// public sealed record InstanceDto( string Id, string Name, string Zustand, int? Prozess, bool Autostart); /// Was beim Anlegen mitkommt. public sealed record NewInstanceDto(string Name); /// Antwort auf etwas, das gelingen oder scheitern kann. public sealed record OutcomeDto(bool Ok, string Meldung); /// Eine gefundene Sicherungsdatei einer Instanz. public sealed record BackupItemDto( string FileName, long SizeBytes, DateTime CreatedAt, string InstanceName, string Secrets, int SecretCount, int FileCount, bool Verschluesselt, bool FremdeLizenz); /// Übersicht aller Sicherungen einer Instanz mit Verzeichnisangabe. public sealed record BackupListDto( string Directory, IReadOnlyList Backups); /// /// Parameter zum Anlegen einer neuen Sicherung. Wie die Zugangsdaten geschützt werden, /// ist keine Frage mehr: Der Manager verschlüsselt immer mit dem Lizenzschlüssel. /// public sealed record CreateBackupRequestDto( bool IncludeLogs, bool IncludeChatHistory); /// Parameter zur Wiederherstellung einer Sicherung. /// Nur für ältere Sicherungen mit Passphrase. /// /// Der Lizenzschlüssel der Installation, von der die Sicherung stammt — nur nötig, wenn /// es nicht die eigene ist. /// public sealed record RestoreBackupRequestDto( string? Passphrase, string? Lizenzschluessel); /// Antwort, wenn für eine Sicherung ein anderer Lizenzschlüssel gebraucht wird. public sealed record KeyNeededDto(bool Ok, string Meldung, string Code); /// Die FTP-Einstellungen, wie die Oberfläche sie sieht — ohne Kennwort. public sealed record FtpSettingsDto( bool Enabled, string Host, int Port, string Username, bool KennwortGesetzt, string RemoteDirectory, bool RequireTls, int KeepCount); /// Neue FTP-Einstellungen. Ein leeres Kennwort heißt: unverändert. public sealed record FtpSettingsUpdateDto( bool Enabled, string Host, int Port, string Username, string? Password, string RemoteDirectory, bool RequireTls, int KeepCount); /// #764: The license as the web interface sees it — never the key, only whether one is set. public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId); /// #764: A license key entered in the web interface. public sealed record LicenseKeyDto(string Key); /// #764: The manager's own settings — without the master token, only whether one is set. public sealed record ManagerSettingsDto( string ConfigFile, string InstancesDirectory, int WebUiPort, string WebUiBinding, string DeploymentcenterUrl, bool MasterTokenSet, string UpdateChannel); /// #764: A change to the manager's settings. null keeps a value, an empty token too. public sealed record ManagerSettingsUpdateDto(string? DeploymentcenterUrl, string? MasterToken, string? UpdateChannel); /// /// Die HTTP-Fläche des Managers. /// /// Zwei Sorten, und der Unterschied ist die ganze Architektur: Unter /// /api/verwaltung steht, was den Manager selbst angeht — welche Instanzen es gibt, /// anlegen, starten, anhalten. Alles andere unter /api wird unverändert an eine /// Instanz weitergereicht. /// /// Wer hier einen Endpunkt ergänzt, der etwas über Agenten, Aufgaben oder /// Freigaben weiß, hat Fachlogik in den Manager gelegt — die gehört in die Instanz, wo /// die Avalonia-Oberfläche sie auch findet. /// internal static class ManagerEndpoints { /// /// #764: Upper limit for an uploaded backup. Kestrel's default of 30 MB is far below a /// backup with chat histories and state.db; without a limit, an upload could fill the disk. /// private const long MaxImportBytes = 4L * 1024 * 1024 * 1024; // ─── Was den Manager selbst angeht ─── public static void MapVerwaltung(this WebApplication app) { var verwaltung = app.MapGroup("/api/verwaltung"); verwaltung.MapGet("/instanzen", (InstanceRegistry registry) => registry.Instances.Select(Map).ToList()); verwaltung.MapPost("/instanzen", async ( NewInstanceDto neu, ManagerCore manager, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(neu.Name)) return Results.BadRequest(new OutcomeDto(false, "Eine Instanz braucht einen Namen.")); try { var pfad = await manager.CreateInstanceAsync(neu.Name.Trim(), ct); return Results.Ok(new OutcomeDto( true, $"Instanz '{neu.Name.Trim()}' angelegt: {pfad}")); } catch (InvalidOperationException ex) { // Gibt es schon. Das ist eine Antwort auf eine Frage und keine Stoerung // des Servers - deshalb 400 und nicht 500. return Results.BadRequest(new OutcomeDto(false, ex.Message)); } }); verwaltung.MapPost("/instanzen/{id}/start", async ( string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) => { if (Find(registry, id) is not { } instanz) return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); var ergebnis = await manager.StartInstanceAsync(instanz.Directory, ct); return ergebnis switch { StartRefusal.None => Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' läuft.")), // 402: Der Grund liegt nicht an der Anfrage, sondern an der Lizenz. StartRefusal.NoLicense => Results.Json( new OutcomeDto(false, "Ohne gültige Lizenz startet keine Instanz."), statusCode: StatusCodes.Status402PaymentRequired), _ => Results.Json( new OutcomeDto(false, $"Instanz '{instanz.Name}' ist nicht hochgekommen. " + "Der Grund steht im Protokoll des Managers."), statusCode: StatusCodes.Status500InternalServerError) }; }); // Der Wechsel, wie E-M2 ihn meint: Alle laufen, eine kommt nach vorn. Frueher // hiess "Instanz wechseln", dass sich der laufende Prozess herunterfaehrt und mit // einer anderen Instanz neu startet - wer wechselte, beendete zwangslaeufig die // Instanz, aus der heraus er es tat. verwaltung.MapPost("/instanzen/{id}/fenster", async ( string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) => { if (Find(registry, id) is not { } instanz) return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); return await manager.FocusInstanceAsync(instanz.Directory, ct) ? Results.Ok(new OutcomeDto(true, $"Fenster von '{instanz.Name}' nach vorn geholt.")) : Results.Json( new OutcomeDto(false, $"Instanz '{instanz.Name}' hat kein Fenster nach vorn zu holen — " + "sie läuft nicht oder läuft kopflos."), statusCode: StatusCodes.Status409Conflict); }); verwaltung.MapPost("/instanzen/{id}/stop", async ( string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) => { if (Find(registry, id) is not { } instanz) return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); var beendet = await manager.StopInstanceAsync(instanz.Directory, ct); return beendet ? Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' beendet.")) : Results.Json( new OutcomeDto(false, $"Instanz '{instanz.Name}' hört nicht und ist von hier aus nicht zu beenden."), statusCode: StatusCodes.Status409Conflict); }); // ─── Datensicherung & Wiederherstellung (#530) ─── // // Jede Sicherung, die der Manager anlegt, ist als Ganzes mit dem Lizenzschluessel // verschluesselt (LicenseBackupCipher). Beim Zurueckspielen wird zuerst der eigene // probiert; passt er nicht, antwortet der Endpunkt mit 422 und dem Code // "fremde_lizenz", und die Oberflaeche fragt nach dem Schluessel der Quelle. verwaltung.MapGet("/instanzen/{id}/backups", async ( string id, InstanceRegistry registry, ManagerBackups backups, CancellationToken ct) => { if (Find(registry, id) is not { } instanz) return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); var ordner = backups.BackupDirectory(instanz); if (!Directory.Exists(ordner)) return Results.Ok(new BackupListDto(ordner, [])); var service = new BackupService(); var liste = new List(); var dateien = new DirectoryInfo(ordner).GetFiles() .Where(f => BackupNaming.IsBackupFile(f.Name)) .OrderByDescending(f => f.LastWriteTimeUtc); foreach (var f in dateien) { ct.ThrowIfCancellationRequested(); var verschluesselt = LicenseBackupCipher.IsEncrypted(f.FullName); try { var manifest = await service.InspectAsync(f.FullName, ct, backups.LicenseKey); liste.Add(new BackupItemDto( f.Name, f.Length, manifest.CreatedAt != default ? manifest.CreatedAt : f.CreationTimeUtc, manifest.InstanceName, manifest.Secrets, manifest.SecretCount, manifest.Files.Count, verschluesselt, FremdeLizenz: false)); } catch (BackupKeyMismatchException) { // Stammt von einer anderen Installation. Kein Fehler, sondern eine // Auskunft - zurueckspielen laesst sie sich mit deren Schluessel. liste.Add(new BackupItemDto( f.Name, f.Length, f.CreationTimeUtc, instanz.Name, nameof(SecretMode.License), 0, 0, Verschluesselt: true, FremdeLizenz: true)); } catch (Exception ex) when (ex is BackupException or IOException or InvalidDataException) { liste.Add(new BackupItemDto( f.Name, f.Length, f.CreationTimeUtc, instanz.Name, "Unlesbar", 0, 0, verschluesselt, FremdeLizenz: false)); } } return Results.Ok(new BackupListDto(ordner, liste)); }); verwaltung.MapPost("/instanzen/{id}/backups", async ( string id, CreateBackupRequestDto? req, InstanceRegistry registry, ManagerBackups backups) => { if (Find(registry, id) is not { } instanz) return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); try { // Ohne das Token der Anfrage: Wer den Browser schliesst, soll die // Sicherung nicht mitten im Schreiben abbrechen. var ergebnis = await backups.CreateAsync( instanz, req?.IncludeLogs ?? false, req?.IncludeChatHistory ?? true, CancellationToken.None); var upload = ergebnis switch { { Uploaded: true } => " Auf den FTP-Server hochgeladen.", { UploadProblem: { } problem } => $" Der Upload ist gescheitert: {problem}", _ => "" }; return Results.Ok(new OutcomeDto(true, $"Sicherung '{ergebnis.FileName}' erstellt ({ergebnis.SizeBytes / 1024} KB).{upload}")); } catch (BackupException ex) { return Results.BadRequest(new OutcomeDto(false, ex.Message)); } }); verwaltung.MapPost("/instanzen/{id}/backups/{filename}/restore", async ( string id, string filename, RestoreBackupRequestDto? req, ManagerCore manager, InstanceRegistry registry, ManagerBackups backups) => { if (Find(registry, id) is not { } instanz) return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); if (!GueltigerDateiname(filename)) return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname.")); var pfad = Path.Combine(backups.BackupDirectory(instanz), filename); if (!File.Exists(pfad)) return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' wurde nicht gefunden.")); string? schluessel = null; if (LicenseBackupCipher.IsEncrypted(pfad)) { var angegeben = !string.IsNullOrWhiteSpace(req?.Lizenzschluessel); schluessel = angegeben ? req!.Lizenzschluessel : backups.LicenseKey; // Die Probe VOR dem Anhalten. Wer erst anhaelt und dann merkt, dass der // Schluessel nicht passt, hat eine laufende Instanz fuer nichts gestoppt. if (schluessel is null || !LicenseBackupCipher.MatchesKey(pfad, schluessel)) { return Results.Json(new KeyNeededDto(false, angegeben ? "Der angegebene Lizenzschlüssel passt nicht zu dieser Sicherung." : "Diese Sicherung stammt von einer anderen Installation. Zum Zurückspielen " + "wird deren Lizenzschlüssel gebraucht.", "fremde_lizenz"), statusCode: StatusCodes.Status422UnprocessableEntity); } } // Ausdruecklich ohne das Token der Anfrage: Wer mitten im Zurueckspielen den // Browser schliesst, hinterliesse sonst ein halb ueberschriebenes // Instanzverzeichnis - und das wuerde anschliessend auch noch gestartet. var ct = CancellationToken.None; var liefVorher = instanz.Condition is InstanceCondition.Running or InstanceCondition.Silent; if (liefVorher && !await manager.StopInstanceAsync(instanz.Directory, ct)) { return Results.Json( new OutcomeDto(false, $"Instanz '{instanz.Name}' konnte vor der Wiederherstellung nicht angehalten werden."), statusCode: StatusCodes.Status409Conflict); } try { var ergebnis = await new BackupService().RestoreAsync(pfad, instanz.Directory, new RestoreOptions { Passphrase = req?.Passphrase, LicenseKey = schluessel, Overwrite = true }, ct); if (liefVorher) await manager.StartInstanceAsync(instanz.Directory, ct); return Results.Ok(new OutcomeDto(true, $"Sicherung '{filename}' wiederhergestellt ({ergebnis.Written.Count} Dateien geschrieben).")); } catch (Exception ex) { if (liefVorher) { try { await manager.StartInstanceAsync(instanz.Directory, ct); } catch { /* Der eigentliche Fehler ist der, der gemeldet wird. */ } } return Results.BadRequest(new OutcomeDto(false, $"Wiederherstellung fehlgeschlagen: {ex.Message}")); } }); verwaltung.MapDelete("/instanzen/{id}/backups/{filename}", ( string id, string filename, InstanceRegistry registry, ManagerBackups backups) => { if (Find(registry, id) is not { } instanz) return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); if (!GueltigerDateiname(filename)) return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname.")); var pfad = Path.Combine(backups.BackupDirectory(instanz), filename); if (!File.Exists(pfad)) return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' nicht gefunden.")); try { File.Delete(pfad); return Results.Ok(new OutcomeDto(true, $"Sicherungsdatei '{filename}' gelöscht.")); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return Results.BadRequest(new OutcomeDto(false, $"Konnte nicht gelöscht werden: {ex.Message}")); } }); // ─── Import a backup as a new instance (#764) ─── // // The file comes as the raw request body, not as a form: the access token is a header, // not a cookie, so there is no cross-site form to guard against, and a raw body needs // no antiforgery exception. The source license key travels in a header and never in // the address, where it would end up in logs. verwaltung.MapPost("/backups/import", async ( HttpContext context, ManagerBackups backups, CancellationToken ct) => { var name = context.Request.Query["name"].ToString(); if (string.IsNullOrWhiteSpace(name)) return Results.BadRequest(new OutcomeDto(false, "The new instance needs a name.")); if (context.Features.Get() is { IsReadOnly: false } limit) limit.MaxRequestBodySize = MaxImportBytes; var upload = Path.Combine(Path.GetTempPath(), "clawd-import-" + Guid.NewGuid().ToString("N") + ".bin"); try { await using (var file = File.Create(upload)) await context.Request.Body.CopyToAsync(file, ct); if (new FileInfo(upload).Length == 0) return Results.BadRequest(new OutcomeDto(false, "The upload was empty.")); var result = await backups.ImportAsNewInstanceAsync( upload, name, context.Request.Headers["X-Source-License-Key"].ToString(), context.Request.Headers["X-Backup-Passphrase"].ToString(), CancellationToken.None); if (result.KeyNeeded) return Results.Json(new KeyNeededDto(false, result.Message, "fremde_lizenz"), statusCode: StatusCodes.Status422UnprocessableEntity); return result.Ok ? Results.Ok(new OutcomeDto(true, result.Message)) : Results.BadRequest(new OutcomeDto(false, result.Message)); } catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex) when (ex.StatusCode == StatusCodes.Status413PayloadTooLarge) { return Results.Json(new OutcomeDto(false, $"The backup is larger than {MaxImportBytes / (1024 * 1024 * 1024)} GB."), statusCode: StatusCodes.Status413PayloadTooLarge); } finally { try { File.Delete(upload); } catch (IOException) { /* temp file; the next cleanup of the temp folder takes it */ } } }); // ─── Updates (#795) ─── verwaltung.MapGet("/update", async (ManagerUpdates updates, HttpContext context, CancellationToken ct) => Results.Ok(context.Request.Query.ContainsKey("refresh") || updates.Status.CheckedAt is null ? await updates.CheckAsync(ct) : updates.Status)); verwaltung.MapPost("/update/install", async (ManagerUpdates updates) => { var result = await updates.InstallAsync(); return result.Ok ? Results.Ok(new OutcomeDto(true, result.Message)) : Results.BadRequest(new OutcomeDto(false, result.Message)); }); // ─── License and manager settings (#764) ─── verwaltung.MapGet("/license", (ManagerCore manager) => Results.Ok(new LicenseStatusDto( manager.LicenseEnforced, manager.LicenseHolds, !string.IsNullOrWhiteSpace(manager.Settings.LicenseKey), manager.LicenseHardwareId))); // The key goes to the license server before it is stored; a refused key leaves the // current one in place (LicenseGate.ApplyKeyAsync). verwaltung.MapPut("/license", async (LicenseKeyDto input, ManagerCore manager, CancellationToken ct) => { var result = await manager.ApplyLicenseKeyAsync(input.Key ?? "", ct); return result.Applied ? Results.Ok(new OutcomeDto(true, result.Message)) : Results.BadRequest(new OutcomeDto(false, result.Message)); }); verwaltung.MapGet("/settings", (ManagerCore manager) => { var s = manager.Settings; return Results.Ok(new ManagerSettingsDto( manager.SettingsPath, s.InstancesDirectory, s.WebUiPort, s.WebUiBinding, s.DeploymentcenterUrl, !string.IsNullOrWhiteSpace(s.DeploymentcenterMasterToken), s.UpdateChannel)); }); verwaltung.MapPut("/settings", (ManagerSettingsUpdateDto input, ManagerCore manager) => { var (ok, message) = manager.UpdateSettings(input.DeploymentcenterUrl, input.MasterToken, input.UpdateChannel); return ok ? Results.Ok(new OutcomeDto(true, message)) : Results.BadRequest(new OutcomeDto(false, message)); }); // ─── FTP-Upload der Sicherungen ─── verwaltung.MapGet("/sicherung/ftp", (ManagerBackups backups) => { var s = backups.FtpSettings; // Das Kennwort verlaesst den Manager nie - nur, ob eines gesetzt ist. return Results.Ok(new FtpSettingsDto( s.Enabled, s.Host, s.Port, s.Username, !string.IsNullOrEmpty(s.Password), s.RemoteDirectory, s.RequireTls, s.KeepCount)); }); verwaltung.MapPut("/sicherung/ftp", (FtpSettingsUpdateDto neu, ManagerBackups backups) => { if (neu.Enabled && string.IsNullOrWhiteSpace(neu.Host)) return Results.BadRequest(new OutcomeDto(false, "Ohne Server lässt sich der Upload nicht einschalten.")); backups.UpdateFtp(new BackupFtpSettings { Enabled = neu.Enabled, Host = neu.Host.Trim(), Port = neu.Port is > 0 and < 65536 ? neu.Port : 21, Username = neu.Username.Trim(), RemoteDirectory = string.IsNullOrWhiteSpace(neu.RemoteDirectory) ? "/clawd-backups" : neu.RemoteDirectory.Trim(), RequireTls = neu.RequireTls, KeepCount = Math.Clamp(neu.KeepCount, 1, 365) }, neu.Password); return Results.Ok(new OutcomeDto(true, "FTP-Einstellungen gespeichert.")); }); verwaltung.MapPost("/sicherung/ftp/test", async (ManagerBackups backups, CancellationToken ct) => { if (backups.Uploader is not { } uploader) return Results.BadRequest(new OutcomeDto(false, "Kein Upload eingerichtet.")); var problem = await uploader.TestAsync(ct); return problem is null ? Results.Ok(new OutcomeDto(true, "Verbindung zum FTP-Server steht (mit TLS).")) : Results.Ok(new OutcomeDto(false, problem)); }); } // ─── Der Ereignisstrom ─── public static void MapEvents(this WebApplication app) { var logger = app.Services .GetRequiredService() .CreateLogger("ClawdDotNet.Manager.Web"); // Zwei Stroeme, dieselbe Zusammenfuehrung: die Kernereignisse und das Protokoll. // Beide gewinnen dadurch, dass an jeder Zeile steht, aus welcher Instanz sie kommt. app.MapGet("/events", (HttpContext context, InstanceRegistry registry, CancellationToken ct) => MergedEventStream.StreamAsync(context, registry, "/events", logger, ct)); app.MapGet("/events/logs", (HttpContext context, InstanceRegistry registry, CancellationToken ct) => MergedEventStream.StreamAsync( context, registry, "/events/logs" + context.Request.QueryString, logger, ct)); } // ─── Alles andere: durchreichen ─── public static void MapForwarding(this WebApplication app) { // #713: The MCP server of an instance, for a human's own client (Claude Code). No // manager token: the MCP token is the key, and the instance checks it. Browsers are // turned away — a page must not reach it through the visitor. app.MapPost("/mcp/{instanz}", async ( HttpContext context, string instanz, InstanceRegistry registry, InstanceForwarding forwarding, CancellationToken ct) => { if (context.Request.Headers.Origin.Count > 0) { context.Response.StatusCode = StatusCodes.Status403Forbidden; return; } if (Find(registry, instanz) is not { } ziel) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } await forwarding.ForwardMcpAsync(context, ziel, ct); }); // #725: An inbound trigger of an instance — n8n or any system that sends a request. Like // MCP: no manager token, the trigger's own token is the key, and it goes to the // instance's MCP socket, which leads to nothing else. app.MapPost("/hooks/{instanz}/trigger", async ( HttpContext context, string instanz, InstanceRegistry registry, InstanceForwarding forwarding, CancellationToken ct) => { if (context.Request.Headers.Origin.Count > 0) { context.Response.StatusCode = StatusCodes.Status403Forbidden; return; } if (Find(registry, instanz) is not { } ziel) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } await forwarding.ForwardMcpAsync(context, ziel, ct, "/hooks/trigger"); }); // The same with the trigger's name in the address — readable for whoever sets up the sender. app.MapPost("/hooks/{instanz}/trigger/{name}", async ( HttpContext context, string instanz, string name, InstanceRegistry registry, InstanceForwarding forwarding, CancellationToken ct) => { if (context.Request.Headers.Origin.Count > 0) { context.Response.StatusCode = StatusCodes.Status403Forbidden; return; } if (Find(registry, instanz) is not { } ziel) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } await forwarding.ForwardMcpAsync(context, ziel, ct, "/hooks/trigger/" + Uri.EscapeDataString(name)); }); app.Map("/api/{**pfad}", async ( HttpContext context, InstanceRegistry registry, InstanceForwarding forwarding, CancellationToken ct) => { var (instanz, problem) = InstanceRouting.Resolve(registry, context); if (instanz is null) { // 409 und nicht 404: Der Pfad ist in Ordnung, nur ist unklar oder // unmoeglich, an wen er gehen soll. context.Response.StatusCode = StatusCodes.Status409Conflict; await context.Response.WriteAsJsonAsync(new OutcomeDto(false, problem!), ct); return; } await forwarding.ForwardAsync(context, instanz, context.Request.Path, ct); }); } // ─── Kleinkram ─── private static InstanceDto Map(ManagedInstance instanz) => new( InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), instanz.ProcessId, instanz.AutostartByManager); private static ManagedInstance? Find(InstanceRegistry registry, string id) => registry.Instances.FirstOrDefault( i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase)); private static bool GueltigerDateiname(string filename) => filename.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && Path.GetFileName(filename) == filename && BackupNaming.IsBackupFile(filename); }