Rueckmeldung aus der Testinstanz: Nach dem Anlegen war nicht zu erkennen,
wofuer ein Trigger da ist, der Agent erfuhr nur 'ein Ereignis kam', und
bearbeiten ging nicht.
- Slug aus dem Namen in der Adresse: /hooks/<instanz>/trigger/<slug>;
ein Token an fremder Adresse oeffnet nichts. Die alte Adresse ohne
Slug bleibt gueltig.
- Auftrag vom Menschen steht vor den gelieferten Daten (die weiter als
Fremdinhalt gerahmt sind); ohne Auftrag wird der Agent darauf hingewiesen.
- Prioritaet der erzeugten Aufgaben.
- PUT /api/triggers/{id} (Token bleibt), POST /api/triggers/{id}/token.
- WebUI: Formular fuer Anlegen und Bearbeiten, Adresse und Auftrag je Zeile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
529 lines
23 KiB
C#
529 lines
23 KiB
C#
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;
|
|
|
|
/// <summary>Eine Instanz, wie die Weboberfläche sie sieht.</summary>
|
|
/// <param name="Autostart">
|
|
/// 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.
|
|
/// </param>
|
|
public sealed record InstanceDto(
|
|
string Id, string Name, string Zustand, int? Prozess, bool Autostart);
|
|
|
|
/// <summary>Was beim Anlegen mitkommt.</summary>
|
|
public sealed record NewInstanceDto(string Name);
|
|
|
|
/// <summary>Antwort auf etwas, das gelingen oder scheitern kann.</summary>
|
|
public sealed record OutcomeDto(bool Ok, string Meldung);
|
|
|
|
/// <summary>Eine gefundene Sicherungsdatei einer Instanz.</summary>
|
|
public sealed record BackupItemDto(
|
|
string FileName,
|
|
long SizeBytes,
|
|
DateTime CreatedAt,
|
|
string InstanceName,
|
|
string Secrets,
|
|
int SecretCount,
|
|
int FileCount,
|
|
bool Verschluesselt,
|
|
bool FremdeLizenz);
|
|
|
|
/// <summary>Übersicht aller Sicherungen einer Instanz mit Verzeichnisangabe.</summary>
|
|
public sealed record BackupListDto(
|
|
string Directory,
|
|
IReadOnlyList<BackupItemDto> Backups);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed record CreateBackupRequestDto(
|
|
bool IncludeLogs,
|
|
bool IncludeChatHistory);
|
|
|
|
/// <summary>Parameter zur Wiederherstellung einer Sicherung.</summary>
|
|
/// <param name="Passphrase">Nur für ältere Sicherungen mit Passphrase.</param>
|
|
/// <param name="Lizenzschluessel">
|
|
/// Der Lizenzschlüssel der Installation, von der die Sicherung stammt — nur nötig, wenn
|
|
/// es nicht die eigene ist.
|
|
/// </param>
|
|
public sealed record RestoreBackupRequestDto(
|
|
string? Passphrase,
|
|
string? Lizenzschluessel);
|
|
|
|
/// <summary>Antwort, wenn für eine Sicherung ein anderer Lizenzschlüssel gebraucht wird.</summary>
|
|
public sealed record KeyNeededDto(bool Ok, string Meldung, string Code);
|
|
|
|
/// <summary>Die FTP-Einstellungen, wie die Oberfläche sie sieht — ohne Kennwort.</summary>
|
|
public sealed record FtpSettingsDto(
|
|
bool Enabled, string Host, int Port, string Username, bool KennwortGesetzt,
|
|
string RemoteDirectory, bool RequireTls, int KeepCount);
|
|
|
|
/// <summary>Neue FTP-Einstellungen. Ein leeres Kennwort heißt: unverändert.</summary>
|
|
public sealed record FtpSettingsUpdateDto(
|
|
bool Enabled, string Host, int Port, string Username, string? Password,
|
|
string RemoteDirectory, bool RequireTls, int KeepCount);
|
|
|
|
/// <summary>
|
|
/// Die HTTP-Fläche des Managers.
|
|
///
|
|
/// <para><b>Zwei Sorten, und der Unterschied ist die ganze Architektur:</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> wird unverändert an eine
|
|
/// Instanz weitergereicht.</para>
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
internal static class ManagerEndpoints
|
|
{
|
|
// ─── 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<BackupItemDto>();
|
|
|
|
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}"));
|
|
}
|
|
});
|
|
|
|
// ─── 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<ILoggerFactory>()
|
|
.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);
|
|
}
|