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>
This commit is contained in:
Richard
2026-09-11 11:03:25 +02:00
co-authored by Claude Opus 5
parent 99f8a89fd0
commit fb185ca84d
17 changed files with 2140 additions and 389 deletions
+172 -134
View File
@@ -34,23 +34,45 @@ public sealed record BackupItemDto(
string InstanceName,
string Secrets,
int SecretCount,
int FileCount);
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.</summary>
/// <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(
string? Secrets,
string? Passphrase,
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? 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.
@@ -157,158 +179,164 @@ internal static class ManagerEndpoints
statusCode: StatusCodes.Status409Conflict);
});
// ─── Datensicherung & Wiederherstellung (#530, Stufe 6) ───
// ─── 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, ILoggerFactory protokoll,
CancellationToken ct) =>
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 backupDir = GetBackupDirectory(instanz, Log(protokoll));
if (!Directory.Exists(backupDir))
return Results.Ok(new BackupListDto(backupDir, []));
var ordner = backups.BackupDirectory(instanz);
if (!Directory.Exists(ordner))
return Results.Ok(new BackupListDto(ordner, []));
var service = new BackupService();
var list = new List<BackupItemDto>();
var liste = new List<BackupItemDto>();
var files = new DirectoryInfo(backupDir)
.GetFiles("*.zip")
var dateien = new DirectoryInfo(ordner).GetFiles()
.Where(f => BackupNaming.IsBackupFile(f.Name))
.OrderByDescending(f => f.LastWriteTimeUtc);
foreach (var f in files)
foreach (var f in dateien)
{
ct.ThrowIfCancellationRequested();
var verschluesselt = LicenseBackupCipher.IsEncrypted(f.FullName);
try
{
var manifest = await service.InspectAsync(f.FullName, ct);
list.Add(new BackupItemDto(
f.Name,
f.Length,
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));
manifest.InstanceName, manifest.Secrets, manifest.SecretCount,
manifest.Files.Count, verschluesselt, FremdeLizenz: false));
}
catch
catch (BackupKeyMismatchException)
{
list.Add(new BackupItemDto(
f.Name,
f.Length,
f.CreationTimeUtc,
instanz.Name,
"Unbekannt",
0,
0));
// 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(backupDir, list));
return Results.Ok(new BackupListDto(ordner, liste));
});
verwaltung.MapPost("/instanzen/{id}/backups", async (
string id, CreateBackupRequestDto? req, InstanceRegistry registry,
ILoggerFactory protokoll, CancellationToken ct) =>
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}'."));
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
Directory.CreateDirectory(backupDir);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var safeName = string.Join("_", instanz.Name.Split(Path.GetInvalidFileNameChars()));
var zipName = $"backup_{safeName}_{timestamp}.zip";
var zipPath = Path.Combine(backupDir, zipName);
var secretMode = string.Equals(req?.Secrets, "Passphrase", StringComparison.OrdinalIgnoreCase)
? SecretMode.Passphrase
: SecretMode.Exclude;
if (secretMode == SecretMode.Passphrase && string.IsNullOrWhiteSpace(req?.Passphrase))
return Results.BadRequest(new OutcomeDto(false, "Für den Schutz der Zugangsdaten wird eine Passphrase benötigt."));
var options = new BackupOptions
{
Secrets = secretMode,
Passphrase = req?.Passphrase,
IncludeLogs = req?.IncludeLogs ?? false,
IncludeChatHistory = req?.IncludeChatHistory ?? true
};
try
{
var service = new BackupService();
var result = await service.CreateAsync(instanz.Directory, zipPath, options, ct);
return Results.Ok(new OutcomeDto(true, $"Sicherung '{zipName}' erfolgreich erstellt ({result.SizeBytes / 1024} KB)."));
// 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 (Exception ex)
catch (BackupException ex)
{
return Results.BadRequest(new OutcomeDto(false, $"Sicherung fehlgeschlagen: {ex.Message}"));
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, ILoggerFactory protokoll) =>
InstanceRegistry registry, ManagerBackups backups) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename)
if (!GueltigerDateiname(filename))
return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname."));
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
var zipPath = Path.Combine(backupDir, filename);
var pfad = Path.Combine(backups.BackupDirectory(instanz), filename);
if (!File.Exists(zipPath))
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. Was
// hier beginnt, wird zu Ende gebracht.
// Instanzverzeichnis - und das wuerde anschliessend auch noch gestartet.
var ct = CancellationToken.None;
var wasRunning = instanz.Condition is InstanceCondition.Running or InstanceCondition.Silent;
if (wasRunning)
var liefVorher = instanz.Condition is InstanceCondition.Running or InstanceCondition.Silent;
if (liefVorher && !await manager.StopInstanceAsync(instanz.Directory, ct))
{
var stopped = await manager.StopInstanceAsync(instanz.Directory, ct);
if (!stopped)
{
return Results.Json(
new OutcomeDto(false, $"Instanz '{instanz.Name}' konnte vor der Wiederherstellung nicht angehalten werden."),
statusCode: StatusCodes.Status409Conflict);
}
return Results.Json(
new OutcomeDto(false, $"Instanz '{instanz.Name}' konnte vor der Wiederherstellung nicht angehalten werden."),
statusCode: StatusCodes.Status409Conflict);
}
try
{
var service = new BackupService();
var restoreResult = await service.RestoreAsync(
zipPath,
instanz.Directory,
var ergebnis = await new BackupService().RestoreAsync(pfad, instanz.Directory,
new RestoreOptions
{
Passphrase = req?.Passphrase,
LicenseKey = schluessel,
Overwrite = true
},
ct);
}, ct);
if (wasRunning)
{
if (liefVorher)
await manager.StartInstanceAsync(instanz.Directory, ct);
}
return Results.Ok(new OutcomeDto(true, $"Sicherung '{filename}' erfolgreich wiederhergestellt ({restoreResult.Written.Count} Dateien geschrieben)."));
return Results.Ok(new OutcomeDto(true,
$"Sicherung '{filename}' wiederhergestellt ({ergebnis.Written.Count} Dateien geschrieben)."));
}
catch (Exception ex)
{
if (wasRunning)
if (liefVorher)
{
try { await manager.StartInstanceAsync(instanz.Directory, ct); } catch { }
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}"));
@@ -316,30 +344,72 @@ internal static class ManagerEndpoints
});
verwaltung.MapDelete("/instanzen/{id}/backups/{filename}", (
string id, string filename, InstanceRegistry registry, ILoggerFactory protokoll) =>
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 (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename)
if (!GueltigerDateiname(filename))
return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname."));
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
var zipPath = Path.Combine(backupDir, filename);
var pfad = Path.Combine(backups.BackupDirectory(instanz), filename);
if (!File.Exists(zipPath))
if (!File.Exists(pfad))
return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' nicht gefunden."));
try
{
File.Delete(zipPath);
File.Delete(pfad);
return Results.Ok(new OutcomeDto(true, $"Sicherungsdatei '{filename}' gelöscht."));
}
catch (Exception ex)
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 ───
@@ -389,44 +459,12 @@ internal static class ManagerEndpoints
InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(),
instanz.ProcessId, instanz.AutostartByManager);
private static ILogger Log(ILoggerFactory factory)
=> factory.CreateLogger("ClawdDotNet.Manager.Web");
private static ManagedInstance? Find(InstanceRegistry registry, string id)
=> registry.Instances.FirstOrDefault(
i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Wo die Sicherungen dieser Instanz liegen.
///
/// <para><b>Eine unlesbare Instanzdatei wird gemeldet, nicht verschluckt.</b> Sonst
/// landeten die Sicherungen wortlos im Standardverzeichnis statt im eingestellten —
/// und niemand sähe, warum die alten verschwunden sind. Genau diese Bauart hat
/// <c>manager.json</c> schon einmal eine halbe Stunde Fehlersuche gekostet.</para>
/// </summary>
private static string GetBackupDirectory(ManagedInstance instanz, ILogger? logger = null)
{
InstanceConfig? config = null;
var settingsFile = Path.Combine(instanz.Directory, "InstanceSettings.json");
if (File.Exists(settingsFile))
{
try
{
config = JsonSerializer.Deserialize<InstanceConfig>(
File.ReadAllText(settingsFile),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (Exception ex)
{
logger?.LogError(ex,
"InstanceSettings.json von {Name} ist nicht lesbar — für die Sicherungen "
+ "gilt das Standardverzeichnis, nicht das dort eingetragene.",
instanz.Name);
}
}
return InstanceLayout.ResolveBackupDirectory(
config ?? new InstanceConfig { WorkingDirectory = instanz.Directory },
instanz.Directory);
}
private static bool GueltigerDateiname(string filename)
=> filename.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
&& Path.GetFileName(filename) == filename
&& BackupNaming.IsBackupFile(filename);
}