Gesichert wird alles, was sich nicht wiederherstellen laesst: Identity und Soul, Konfigurationen, Arbeitsverzeichnisse, Chatverlaeufe und vor allem die Datenbank mit dem Langzeitgedaechtnis. Protokolle bleiben standardmaessig aussen vor. Zwei Punkte waren dabei nicht offensichtlich. Die Datenbank darf nicht einfach kopiert werden. Mit WAL stehen die juengsten Aenderungen in der Begleitdatei, nicht in der Hauptdatei — eine reine Kopie waere veraltet oder in sich widersprueckhlich. VACUUM INTO erzeugt dagegen im laufenden Betrieb eine geschlossene, konsistente Kopie; die WAL-Begleitdateien gehoeren dann nicht mehr ins Archiv. Zugangsdaten sind seit S7 mit DPAPI geschuetzt und damit an Benutzer und Rechner gebunden. In einer Sicherung waeren sie genau dann unbrauchbar, wenn man sie braucht — bei einem defekten Rechner. PassphraseProtector schluesselt sie deshalb beim Sichern auf eine Passphrase um (PBKDF2 mit 210.000 Runden, AES-GCM) und beim Wiederherstellen zurueck auf DPAPI des Zielrechners. Alternativ laesst sich eine Sicherung ganz ohne Zugangsdaten erstellen; sie ist dann gefahrlos ablegbar, die Wiederherstellung aber unvollstaendig. Das Umschluesseln arbeitet auf dem JSON-Baum statt ueber die typisierten Konfigurationsklassen. Beim Deserialisieren und erneuten Serialisieren gingen unbekannte Felder verloren — eine Sicherung darf aber nichts wegwerfen, nur weil eine Programmfassung ein Feld nicht kennt. Ein Test haelt das fest. Weitere Eigenschaften: Manifest mit Pruefsummen je Datei, sodass ein veraendertes Archiv auffaellt, bevor etwas ueberschrieben wird. Vorschau-Modus. Vorhandene Dateien werden ohne ausdrueckliche Zustimmung nicht ueberschrieben. Eintraege, die aus dem Zielverzeichnis herauszeigen, werden abgelehnt. Der wichtigste Test ist der vollstaendige Rundlauf: Instanz aufbauen, sichern, in ein leeres Verzeichnis wiederherstellen und pruefen, dass Persoenlichkeit, Arbeitsstand, Gedaechtnis und nutzbare Zugangsdaten zurueck sind. Ein ungepruefte Wiederherstellung ist kein Backup, sondern eine Vermutung. 385 Tests gruen (237 Core, 148 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
396 lines
16 KiB
C#
396 lines
16 KiB
C#
using System.IO.Compression;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ClawdDotNet.Core.Security;
|
|
using ClawdDotNet.Core.Storage;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ClawdDotNet.Core.Backup;
|
|
|
|
/// <summary>
|
|
/// Sichert eine Instanz vollständig und stellt sie wieder her.
|
|
///
|
|
/// Zwei Dinge sind dabei nicht offensichtlich:
|
|
///
|
|
/// 1. Die Datenbank darf nicht einfach kopiert werden. Mit WAL stehen die jüngsten
|
|
/// Änderungen in der Begleitdatei, nicht in der Hauptdatei — eine reine Kopie wäre
|
|
/// veraltet oder in sich widersprüchlich. <c>VACUUM INTO</c> erzeugt dagegen im
|
|
/// laufenden Betrieb eine geschlossene, konsistente Kopie.
|
|
///
|
|
/// 2. Zugangsdaten sind mit DPAPI geschützt und damit an Benutzer und Rechner
|
|
/// gebunden. In einer Sicherung wären sie genau dann unbrauchbar, wenn man sie
|
|
/// braucht. Sie werden deshalb auf eine Passphrase umgeschlüsselt — oder auf
|
|
/// Wunsch weggelassen.
|
|
/// </summary>
|
|
public sealed class BackupService
|
|
{
|
|
private const string ManifestName = "manifest.json";
|
|
private const string DatabaseName = "state.db";
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
|
|
|
/// <summary>Wird nie mitgesichert — entweder erzeugt oder unerwünscht.</summary>
|
|
private static readonly string[] AlwaysExcludedDirectories = ["bin", "obj", ".vs"];
|
|
|
|
private static readonly string[] AlwaysExcludedExtensions = [".tmp", ".bak"];
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Sichern
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
public async Task<BackupResult> CreateAsync(
|
|
string instanceDir, string targetZipPath, BackupOptions options, CancellationToken ct = default)
|
|
{
|
|
if (!Directory.Exists(instanceDir))
|
|
throw new BackupException($"Instanzverzeichnis nicht gefunden: {instanceDir}");
|
|
|
|
if (options.Secrets == SecretMode.Passphrase && string.IsNullOrEmpty(options.Passphrase))
|
|
throw new BackupException("Für den Schutz der Zugangsdaten wird eine Passphrase benötigt.");
|
|
|
|
var staging = Path.Combine(Path.GetTempPath(), "clawd-backup-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(staging);
|
|
|
|
try
|
|
{
|
|
var files = new List<BackupEntry>();
|
|
var secretCount = 0;
|
|
|
|
// ─── Datenbank konsistent kopieren ───
|
|
var dbPath = Path.Combine(instanceDir, DatabaseName);
|
|
if (File.Exists(dbPath))
|
|
{
|
|
var target = Path.Combine(staging, DatabaseName);
|
|
CopyDatabaseConsistently(dbPath, target);
|
|
files.Add(await DescribeAsync(staging, target, ct));
|
|
}
|
|
|
|
// ─── Übrige Dateien ───
|
|
foreach (var source in EnumerateFiles(instanceDir, options))
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var relative = Path.GetRelativePath(instanceDir, source);
|
|
var target = Path.Combine(staging, relative);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
|
|
|
if (IsConfigFile(relative))
|
|
{
|
|
var original = AtomicFile.ReadAllText(source);
|
|
var (rewritten, count) = RewriteSecretsForBackup(original, options);
|
|
secretCount += count;
|
|
await File.WriteAllTextAsync(target, rewritten, ct);
|
|
}
|
|
else
|
|
{
|
|
File.Copy(source, target, overwrite: true);
|
|
}
|
|
|
|
files.Add(await DescribeAsync(staging, target, ct));
|
|
}
|
|
|
|
// ─── Manifest ───
|
|
var (instanceId, instanceName) = ReadInstanceIdentity(instanceDir);
|
|
|
|
var manifest = new BackupManifest
|
|
{
|
|
CreatedAt = DateTime.Now,
|
|
InstanceId = instanceId,
|
|
InstanceName = instanceName,
|
|
Secrets = options.Secrets.ToString(),
|
|
SecretCount = secretCount,
|
|
Files = files.OrderBy(f => f.Path, StringComparer.OrdinalIgnoreCase).ToList()
|
|
};
|
|
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(staging, ManifestName),
|
|
JsonSerializer.Serialize(manifest, JsonOptions), ct);
|
|
|
|
// ─── Archiv ───
|
|
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(targetZipPath))!);
|
|
if (File.Exists(targetZipPath))
|
|
File.Delete(targetZipPath);
|
|
|
|
ZipFile.CreateFromDirectory(staging, targetZipPath, CompressionLevel.Optimal,
|
|
includeBaseDirectory: false);
|
|
|
|
return new BackupResult(targetZipPath, manifest, new FileInfo(targetZipPath).Length);
|
|
}
|
|
finally
|
|
{
|
|
TryDeleteDirectory(staging);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Erzeugt eine konsistente Kopie der Datenbank, auch während sie in Benutzung ist.
|
|
/// </summary>
|
|
private static void CopyDatabaseConsistently(string sourcePath, string targetPath)
|
|
{
|
|
var connectionString = new SqliteConnectionStringBuilder
|
|
{
|
|
DataSource = sourcePath,
|
|
Mode = SqliteOpenMode.ReadOnly
|
|
}.ToString();
|
|
|
|
using var conn = new SqliteConnection(connectionString);
|
|
conn.Open();
|
|
|
|
using var cmd = conn.CreateCommand();
|
|
// Parameter sind in VACUUM INTO nicht erlaubt, deshalb einfache Anführungszeichen
|
|
// im Pfad verdoppeln.
|
|
cmd.CommandText = $"VACUUM INTO '{targetPath.Replace("'", "''")}'";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Prüfen
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
/// <summary>Liest das Manifest, ohne etwas auszupacken.</summary>
|
|
public async Task<BackupManifest> InspectAsync(string zipPath, CancellationToken ct = default)
|
|
{
|
|
using var archive = ZipFile.OpenRead(zipPath);
|
|
|
|
var entry = archive.GetEntry(ManifestName)
|
|
?? throw new BackupException("Kein Manifest im Archiv — das ist keine ClawdDotNet-Sicherung.");
|
|
|
|
await using var stream = entry.Open();
|
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
|
|
|
var json = await reader.ReadToEndAsync(ct);
|
|
|
|
return JsonSerializer.Deserialize<BackupManifest>(json)
|
|
?? throw new BackupException("Das Manifest ist unlesbar.");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Wiederherstellen
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
public async Task<RestoreResult> RestoreAsync(
|
|
string zipPath, string targetDir, RestoreOptions options, CancellationToken ct = default)
|
|
{
|
|
var manifest = await InspectAsync(zipPath, ct);
|
|
|
|
if (manifest.HasSecrets && string.IsNullOrEmpty(options.Passphrase))
|
|
throw new BackupException(
|
|
"Diese Sicherung enthält geschützte Zugangsdaten. Bitte die Passphrase angeben.");
|
|
|
|
var written = new List<string>();
|
|
var skipped = new List<string>();
|
|
var wouldOverwrite = new List<string>();
|
|
|
|
using var archive = ZipFile.OpenRead(zipPath);
|
|
|
|
foreach (var entry in archive.Entries)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
if (entry.FullName == ManifestName || string.IsNullOrEmpty(entry.Name))
|
|
continue;
|
|
|
|
var relative = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
|
|
var destination = ResolveInside(targetDir, relative);
|
|
|
|
// Prüfsumme gegen das Manifest — ein beschädigtes Archiv soll auffallen,
|
|
// bevor etwas überschrieben wird.
|
|
var expected = manifest.Files.FirstOrDefault(
|
|
f => string.Equals(f.Path, entry.FullName, StringComparison.OrdinalIgnoreCase));
|
|
|
|
var content = await ReadEntryAsync(entry, ct);
|
|
|
|
if (expected is not null && ComputeSha256(content) != expected.Sha256)
|
|
{
|
|
throw new BackupException(
|
|
$"Prüfsumme stimmt nicht für '{entry.FullName}'. Das Archiv ist beschädigt.");
|
|
}
|
|
|
|
if (File.Exists(destination))
|
|
{
|
|
wouldOverwrite.Add(relative);
|
|
|
|
if (!options.Overwrite)
|
|
{
|
|
skipped.Add(relative);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (options.DryRun)
|
|
continue;
|
|
|
|
var restored = manifest.HasSecrets && IsConfigFile(relative)
|
|
? Encoding.UTF8.GetBytes(
|
|
RewriteSecretsForRestore(Encoding.UTF8.GetString(content), options.Passphrase!))
|
|
: content;
|
|
|
|
AtomicFile.WriteAllBytes(destination, restored);
|
|
written.Add(relative);
|
|
}
|
|
|
|
return new RestoreResult(written, skipped, wouldOverwrite);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Zugangsdaten
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
private static (string Json, int SecretCount) RewriteSecretsForBackup(
|
|
string json, BackupOptions options)
|
|
{
|
|
var count = 0;
|
|
|
|
var rewritten = JsonSecretRewriter.Rewrite(json, value =>
|
|
{
|
|
count++;
|
|
|
|
// In der Datei liegt der Wert DPAPI-geschützt; für die Sicherung wird er
|
|
// zunächst gelesen und dann anders geschützt.
|
|
string? plain;
|
|
try
|
|
{
|
|
plain = SecretProtector.Unprotect(value);
|
|
}
|
|
catch (SecretProtectionException)
|
|
{
|
|
// Nicht lesbar — etwa weil die Datei von einem anderen Benutzer stammt.
|
|
// Der Wert darf dann nicht als vermeintlicher Klartext weitergereicht
|
|
// werden.
|
|
return null;
|
|
}
|
|
|
|
return options.Secrets == SecretMode.Passphrase
|
|
? PassphraseProtector.Protect(plain, options.Passphrase!)
|
|
: null;
|
|
});
|
|
|
|
return (rewritten, count);
|
|
}
|
|
|
|
private static string RewriteSecretsForRestore(string json, string passphrase)
|
|
=> JsonSecretRewriter.Rewrite(json, value =>
|
|
{
|
|
if (!PassphraseProtector.IsProtected(value))
|
|
return value;
|
|
|
|
var plain = PassphraseProtector.Unprotect(value, passphrase);
|
|
|
|
// Zurück auf DPAPI des Zielrechners.
|
|
return SecretProtector.Protect(plain);
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Hilfsfunktionen
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
private static IEnumerable<string> EnumerateFiles(string instanceDir, BackupOptions options)
|
|
{
|
|
foreach (var path in Directory.EnumerateFiles(instanceDir, "*", SearchOption.AllDirectories))
|
|
{
|
|
var relative = Path.GetRelativePath(instanceDir, path);
|
|
var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
|
|
if (segments.Any(s => AlwaysExcludedDirectories.Contains(s, StringComparer.OrdinalIgnoreCase)))
|
|
continue;
|
|
|
|
var name = Path.GetFileName(path);
|
|
|
|
// Die Datenbank wird gesondert behandelt; die WAL-Begleitdateien gehören
|
|
// nicht ins Archiv, weil VACUUM INTO sie bereits einarbeitet.
|
|
if (name is DatabaseName or DatabaseName + "-wal" or DatabaseName + "-shm")
|
|
continue;
|
|
|
|
if (AlwaysExcludedExtensions.Contains(Path.GetExtension(name), StringComparer.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
if (name.Contains(".tmp_", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
if (!options.IncludeLogs &&
|
|
segments.Any(s => s.Equals("Logs", StringComparison.OrdinalIgnoreCase)))
|
|
continue;
|
|
|
|
if (!options.IncludeChatHistory &&
|
|
name is "ChatHistory.json" or "ChatContext.json")
|
|
continue;
|
|
|
|
yield return path;
|
|
}
|
|
}
|
|
|
|
private static bool IsConfigFile(string relativePath)
|
|
{
|
|
var name = Path.GetFileName(relativePath);
|
|
return name.Equals("InstanceSettings.json", StringComparison.OrdinalIgnoreCase)
|
|
|| name.Equals("AgentSettings.json", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static (string Id, string Name) ReadInstanceIdentity(string instanceDir)
|
|
{
|
|
var path = Path.Combine(instanceDir, "InstanceSettings.json");
|
|
if (!File.Exists(path))
|
|
return ("", Path.GetFileName(instanceDir.TrimEnd(Path.DirectorySeparatorChar)));
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(AtomicFile.ReadAllText(path));
|
|
var root = doc.RootElement;
|
|
|
|
return (
|
|
root.TryGetProperty("instanceId", out var id) ? id.GetString() ?? "" : "",
|
|
root.TryGetProperty("instanceName", out var n) ? n.GetString() ?? "" : "");
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return ("", "");
|
|
}
|
|
}
|
|
|
|
private static async Task<BackupEntry> DescribeAsync(string root, string file, CancellationToken ct)
|
|
{
|
|
var bytes = await File.ReadAllBytesAsync(file, ct);
|
|
|
|
return new BackupEntry(
|
|
Path.GetRelativePath(root, file).Replace(Path.DirectorySeparatorChar, '/'),
|
|
bytes.LongLength,
|
|
ComputeSha256(bytes));
|
|
}
|
|
|
|
private static async Task<byte[]> ReadEntryAsync(ZipArchiveEntry entry, CancellationToken ct)
|
|
{
|
|
await using var stream = entry.Open();
|
|
using var buffer = new MemoryStream();
|
|
await stream.CopyToAsync(buffer, ct);
|
|
return buffer.ToArray();
|
|
}
|
|
|
|
private static string ComputeSha256(byte[] content)
|
|
=> Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
|
|
|
|
/// <summary>
|
|
/// Verhindert, dass ein präpariertes Archiv über Einträge wie <c>..\..\evil</c>
|
|
/// außerhalb des Zielverzeichnisses schreibt.
|
|
/// </summary>
|
|
private static string ResolveInside(string targetDir, string relative)
|
|
{
|
|
var root = Path.GetFullPath(targetDir);
|
|
var full = Path.GetFullPath(Path.Combine(root, relative));
|
|
|
|
var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar)
|
|
? root
|
|
: root + Path.DirectorySeparatorChar;
|
|
|
|
if (!full.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
|
|
throw new BackupException($"Eintrag '{relative}' zeigt aus dem Zielverzeichnis heraus.");
|
|
|
|
return full;
|
|
}
|
|
|
|
private static void TryDeleteDirectory(string path)
|
|
{
|
|
try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
|
|
catch { /* Aufräumen darf den eigentlichen Vorgang nicht stören */ }
|
|
}
|
|
}
|