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);
}