395 lines
16 KiB
C#
395 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));
|
|
|
|
// Der Vergleich muss dem Dateisystem folgen: Unter Linux sind "Ziel" und "ziel"
|
|
// zwei Verzeichnisse, und ein Eintrag darf auch nicht über eine symbolische
|
|
// Verknüpfung hinauszeigen. Beides steckt in PathBoundary.
|
|
if (!PathBoundary.IsInside(full, root))
|
|
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 */ }
|
|
}
|
|
}
|