diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md index 3d2f91a..d39cded 100644 --- a/docs/Instanzmanager-Konzept.md +++ b/docs/Instanzmanager-Konzept.md @@ -269,10 +269,8 @@ Produktions- und eine Testinstanz nebeneinander betreiben. ## 7 — Noch zu klären -**Sicherungen im Manager — zurückgestellt** (2026-09-08, #530). Erst soll der Rest -laufen. Die gemeinsame Aufbewahrung ist mit Stufe 1 ohnehin erledigt, der -verbleibende Gewinn ist damit kleiner als anfangs gedacht: Es bliebe, auch von -einer Instanz sichern zu können, die gerade nicht läuft. +**Sicherungen im Manager — gebaut** (2026-09-11, #530): verschlüsselt mit dem +Lizenzschlüssel, auf Wunsch per FTP hinaus. Siehe Abschnitt 9.10. **Eine Sache ist daran aber schon entschieden:** Zurückspielen heißt, in ein Instanzverzeichnis zu schreiben, und das darf **ausschließlich bei inaktiver @@ -957,3 +955,79 @@ bestätigt: | `--managed` + gültige Unterschrift | Instanz kommt hoch, meldet ihren Named Pipe | | Unterschrift für ein anderes Verzeichnis | „Lizenz erforderlich" | | Gültige Unterschrift, Ablauf hochgedreht | „Lizenz erforderlich" | + +### 9.10 Sicherungen: verschlüsselt mit dem Lizenzschlüssel + +Entschieden am 2026-09-11, als bewusste Vereinfachung gegenüber dem Schlüsselbund aus #190. + +**Die Vorgabe.** Eine Sicherung wird mit dem Lizenzschlüssel des Managers und einem festen +Salt aus dem Code verschlüsselt. Beim Zurückspielen versucht der Manager den eigenen +Schlüssel; passt er nicht, fragt ein Fenster nach dem Lizenzschlüssel der Quellinstallation. +Die ganze Sicherung liegt als ein verschlüsseltes Archiv vor, auf Wunsch zusätzlich per FTP +hinaus. + +**Warum das trägt.** Der Lizenzschlüssel ist der eine Wert, den jede Installation ohnehin hat +und den ihr Betreiber ohnehin kennt — und er liegt im Manager, nicht neben den Archiven. +Damit fällt der Grund weg, aus dem die tägliche Sicherung bisher **keine** Zugangsdaten +enthielt: Eine Passphrase hätte gespeichert werden müssen, und neben den Sicherungen +abgelegt wäre sie wirkungslos gewesen. Jetzt reisen die Zugangsdaten mit, und eine Sicherung +übersteht den Maschinenwechsel ohne Passphrase. + +**Zwei Abweichungen von der Vorgabe, beide erzwungen:** + +| Vorgabe | Umgesetzt | Warum | +|---|---|---| +| RAR | nicht möglich | RAR zu schreiben braucht das lizenzpflichtige Programm des Herstellers | +| verschlüsseltes ZIP | eigener Behälter `.clawdbak` | .NET kann kein ZIP mit Kennwort schreiben, und das verbreitete ZipCrypto ist gebrochen. Weil der Schlüssel ohnehin abgeleitet wird, ließe sich ein solches ZIP auch mit 7-Zip nicht öffnen — die Verträglichkeit brächte nichts | + +Der Behälter: Kopf mit Kennung, Fassung und einer **Schlüsselprobe**; das Manifest als eigener +Abschnitt; das ZIP in Stücken zu 1 MiB, jedes mit AES-256-GCM und eigener Nonce, das letzte +mit Endmarke. Die Stückelung, weil AES-GCM in einem Zug alles im Speicher bräuchte. Das +Manifest vorn, damit die Übersicht nicht jedes Archiv ganz entschlüsseln muss. Die Endmarke, +damit ein abgeschnittenes Archiv auffällt. Und die Schlüsselprobe, damit „fremder Schlüssel" +von „beschädigt" zu unterscheiden ist, **bevor** etwas angehalten wird. + +**Was das aushält und was nicht — ausdrücklich.** Wer Lizenzschlüssel und Archiv hat, hat +alle Zugangsdaten darin. Der Schlüssel ist dem Hersteller bekannt, und er gilt für alle +Instanzen einer Installation. Das ist die Abwägung, nicht ein Versehen: Ein Archiv auf einem +FTP-Server oder einem USB-Stick soll niemandem nützen, der es nur findet — aufhalten soll es +nicht jemanden, der Schlüssel und Absicht mitbringt. + +**Die Reihenfolge beim Zurückspielen.** Erst die Schlüsselprobe, dann das Anhalten. Wer +anhält und danach merkt, dass der Schlüssel nicht passt, hat eine laufende Instanz für nichts +gestoppt. Passt er nicht, antwortet der Endpunkt mit **422** und dem Code `fremde_lizenz`, +und die Oberfläche öffnet das Fenster für den Schlüssel der Quelle. Groß- und Kleinschreibung +und Leerzeichen spielen dabei keine Rolle — dort tippt ein Mensch einen fremden Schlüssel ab. + +**Geführte Instanzen sichern sich nicht mehr selbst.** Nur der Manager hat den +Lizenzschlüssel. Liefen beide, lägen jeden Tag zwei Archive im Ordner, eines davon +unverschlüsselt und ohne Zugangsdaten. Die Einstellungen — Schalter, Uhrzeit, Aufbewahrung, +Zielordner — bleiben in der `InstanceSettings.json`; der Manager liest sie. Einmal am Tag +heißt dabei auch über einen Neustart des Managers hinweg: Eine Datei von heute, die nach der +eingestellten Uhrzeit entstanden ist, gilt als erledigt. + +**Ohne Lizenzschlüssel im Manager wird nichts gesichert** — nicht ersatzweise unverschlüsselt. +Das steht beim Start als Warnung im Protokoll. + +**FTP.** Hochgeladen wird unter einem vorläufigen Namen und erst danach umbenannt: Eine halbe +Datei unter richtigem Namen würde beim Aufräumen als gültige Sicherung mitgezählt, während +eine vollständige dafür gelöscht wird. Auf dem Server wird je Instanz aufgeräumt, sonst füllt +ein automatischer Upload ihn, bis ausgerechnet die jüngste Sicherung scheitert. TLS wird +genommen, wo der Server es anbietet, und lässt sich mit `requireTls` erzwingen; Zertifikate +werden nicht geprüft, weil eigene FTP-Server meist selbstsignierte haben. Ein gescheiterter +Upload lässt die lokale Sicherung stehen und steht in der Meldung. Das FTP-Kennwort liegt +verschlüsselt in `manager.json` und geht nie an die Oberfläche zurück. + +**Ein Präfix ist keine Grenze.** Welche Datei zu welcher Instanz gehört, entscheidet ein +Zeitstempel unmittelbar nach dem Namen, nicht der Anfang des Namens. Sonst zählte `Test` die +Archive von `Test_2` mit und würfe beim Aufräumen fremde weg (siehe 1.2). + +**Und der Schlüsselbund aus #190?** Er bleibt, verliert aber seine Rolle für Sicherungen: +Dafür ist jetzt der Lizenzschlüssel da. Das Wiederherstellungsfach ist nur noch für den Fall +nötig, dass eine laufende Installation ihr Maschinenfach verliert, ohne dass eine Sicherung +im Spiel ist. Die Naht für #180 bleibt davon unberührt. + +**Nicht über die Oberfläche abgenommen.** Behälter, Sicherungsdienst und der Rundlauf über +einen Maschinenwechsel sind mit Tests belegt; der Weg durch die Weboberfläche — das Fenster +für den fremden Schlüssel, der FTP-Upload gegen einen echten Server — noch nicht. Beides steht +in #537. diff --git a/docs/Roadmap.md b/docs/Roadmap.md index 186175f..5c6b25e 100644 --- a/docs/Roadmap.md +++ b/docs/Roadmap.md @@ -388,15 +388,11 @@ Steht vor #190, weil die Antwort das Backend bestimmt, das dort gebaut wird. > einem Umzug die Passphrase, und beide führen zu **demselben** Schlüssel. Alle > `enc:v2`-Werte bleiben lesbar, nichts muss neu eingetragen werden. > -> **Zwei Wege, die sich ähneln und nicht dasselbe sind** — das ist die Stelle, an der -> man beim Lesen falsch abbiegt: -> -> | | Sicherung mit Passphrase (`BackupService`) | Wiederherstellungsfach (`SecretKeyRing`) | -> |---|---|---| -> | Was umgeschlüsselt wird | jeder **Wert** einzeln, in das Archiv hinein | der **Hauptschlüssel**, in der Schlüsseldatei | -> | Wofür | eine Instanz auf eine andere Maschine bringen | eine bestehende Installation weiterbenutzen, wenn das Maschinenfach nicht mehr aufgeht | -> | Wann entschieden | beim Ziehen der Sicherung | einmal, vorher | -> | Ohne Menschen | nein, die Passphrase gehört zur Sicherung | ja, über `CLAWD_SECRET_PASSPHRASE` | +> **Sicherungen laufen seit dem 2026-09-11 nicht mehr über diesen Bund**, sondern über +> den Lizenzschlüssel des Managers (Instanzmanager-Konzept, Abschnitt 9.10). Eine Sicherung +> übersteht den Maschinenwechsel damit ohne Passphrase. Das Wiederherstellungsfach bleibt +> für den selteneren Fall, dass eine laufende Installation ihr Maschinenfach verliert, ohne +> dass eine Sicherung im Spiel ist. > > **Was noch fehlt, ist genau #180:** Womit das Maschinenfach unter Linux gefüllt wird. > Bis dahin greift dort `file` — dieselbe Schutzstufe wie vorher, aber sie steht jetzt diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 0c93625..f8d59ab 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -412,7 +412,19 @@ public sealed class AppHost : IAsyncDisposable instancePath, instance, loggerFactory.CreateLogger("ClawdDotNet.Backup")); - host.Backups.Start(); + // Eine gefuehrte Instanz sichert sich nicht selbst: Das tut der Manager, und nur er + // hat den Lizenzschluessel, mit dem die Sicherung verschluesselt wird. Liefen + // beide, laegen jeden Tag zwei Archive im Ordner - eines davon ohne Zugangsdaten + // und unverschluesselt. Eigenstaendige Instanzen sichern weiter wie bisher. + if (gefuehrt) + { + logger.LogInformation( + "Die tägliche Sicherung dieser Instanz übernimmt der Instanzmanager."); + } + else + { + host.Backups.Start(); + } host._shutdown.Add(host.Backups.DisposeAsync); return new StartupResult(host, null); diff --git a/src/ClawdDotNet.Core/Backup/BackupModels.cs b/src/ClawdDotNet.Core/Backup/BackupModels.cs index 7bc5479..b3ca500 100644 --- a/src/ClawdDotNet.Core/Backup/BackupModels.cs +++ b/src/ClawdDotNet.Core/Backup/BackupModels.cs @@ -16,7 +16,18 @@ public enum SecretMode /// Zugangsdaten werden mit einer Passphrase geschützt. Nur so überstehen sie /// einen Rechner- oder Benutzerwechsel. /// - Passphrase + Passphrase, + + /// + /// Die ganze Sicherung wird mit dem Lizenzschlüssel verschlüsselt + /// (), die Zugangsdaten reisen mit. + /// + /// Der Weg, den der Instanzmanager nimmt. Er übersteht einen Maschinenwechsel, + /// ohne dass eine Passphrase gespeichert oder eingetippt werden müsste — der + /// Lizenzschlüssel ist ohnehin da. Deshalb darf auch die tägliche Sicherung jetzt + /// Zugangsdaten enthalten. + /// + License } public sealed record BackupOptions @@ -26,6 +37,9 @@ public sealed record BackupOptions /// Erforderlich bei . public string? Passphrase { get; init; } + /// Erforderlich bei . + public string? LicenseKey { get; init; } + /// Protokolle sind groß und selten nötig. public bool IncludeLogs { get; init; } @@ -37,6 +51,12 @@ public sealed record RestoreOptions { public string? Passphrase { get; init; } + /// + /// Für verschlüsselte Sicherungen: der Lizenzschlüssel der Installation, von der sie + /// stammt. Passt er nicht, kommt . + /// + public string? LicenseKey { get; init; } + /// Nur prüfen und berichten, nichts schreiben. public bool DryRun { get; init; } @@ -74,8 +94,12 @@ public sealed record BackupManifest [JsonPropertyName("files")] public List Files { get; init; } = new(); + /// + /// Enthält die Sicherung Zugangsdaten, die beim Zurückspielen auf den Schlüssel der + /// Zielmaschine umgeschlüsselt werden müssen? + /// [JsonIgnore] - public bool HasSecrets => Secrets == nameof(SecretMode.Passphrase); + public bool HasSecrets => Secrets is nameof(SecretMode.Passphrase) or nameof(SecretMode.License); } public sealed record BackupResult(string ZipPath, BackupManifest Manifest, long SizeBytes); @@ -88,5 +112,6 @@ public sealed record RestoreResult( public bool HasConflicts => WouldOverwrite.Count > 0; } -public sealed class BackupException(string message, Exception? inner = null) +/// Nicht versiegelt: ist ein Sonderfall davon. +public class BackupException(string message, Exception? inner = null) : Exception(message, inner); diff --git a/src/ClawdDotNet.Core/Backup/BackupService.cs b/src/ClawdDotNet.Core/Backup/BackupService.cs index 0547ee0..64d7c6c 100644 --- a/src/ClawdDotNet.Core/Backup/BackupService.cs +++ b/src/ClawdDotNet.Core/Backup/BackupService.cs @@ -48,6 +48,9 @@ public sealed class BackupService if (options.Secrets == SecretMode.Passphrase && string.IsNullOrEmpty(options.Passphrase)) throw new BackupException("Für den Schutz der Zugangsdaten wird eine Passphrase benötigt."); + if (options.Secrets == SecretMode.License && string.IsNullOrWhiteSpace(options.LicenseKey)) + throw new BackupException("Ohne Lizenzschlüssel lässt sich keine verschlüsselte Sicherung anlegen."); + var staging = Path.Combine(Path.GetTempPath(), "clawd-backup-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); @@ -111,8 +114,32 @@ public sealed class BackupService if (File.Exists(targetZipPath)) File.Delete(targetZipPath); - ZipFile.CreateFromDirectory(staging, targetZipPath, CompressionLevel.Optimal, - includeBaseDirectory: false); + if (options.Secrets != SecretMode.License) + { + ZipFile.CreateFromDirectory(staging, targetZipPath, CompressionLevel.Optimal, + includeBaseDirectory: false); + + return new BackupResult(targetZipPath, manifest, new FileInfo(targetZipPath).Length); + } + + // Verschluesselt: erst das ZIP neben den Staging-Ordner, dann als Ganzes + // verschluesselt ans Ziel. Das unverschluesselte ZIP verlaesst den temporaeren + // Ordner nie - es liegt dort mit Zugangsdaten, die nur noch durch die + // Passphrase-Schicht geschuetzt sind. + var klarZip = staging + ".zip"; + try + { + ZipFile.CreateFromDirectory(staging, klarZip, CompressionLevel.Optimal, + includeBaseDirectory: false); + + LicenseBackupCipher.Encrypt( + klarZip, JsonSerializer.Serialize(manifest, JsonOptions), + targetZipPath, options.LicenseKey!); + } + finally + { + TryDeleteFile(klarZip); + } return new BackupResult(targetZipPath, manifest, new FileInfo(targetZipPath).Length); } @@ -148,8 +175,24 @@ public sealed class BackupService // ═══════════════════════════════════════════════════════════ /// Liest das Manifest, ohne etwas auszupacken. - public async Task InspectAsync(string zipPath, CancellationToken ct = default) + /// Nur für verschlüsselte Sicherungen nötig. + /// + /// Verschlüsselt, und der Schlüssel passt nicht. + /// + public async Task InspectAsync( + string zipPath, CancellationToken ct = default, string? licenseKey = null) { + if (LicenseBackupCipher.IsEncrypted(zipPath)) + { + if (string.IsNullOrWhiteSpace(licenseKey)) + throw new BackupKeyMismatchException( + "Diese Sicherung ist verschlüsselt; zum Lesen wird ein Lizenzschlüssel gebraucht."); + + return JsonSerializer.Deserialize( + LicenseBackupCipher.ReadManifest(zipPath, licenseKey)) + ?? throw new BackupException("Das Manifest ist unlesbar."); + } + using var archive = ZipFile.OpenRead(zipPath); var entry = archive.GetEntry(ManifestName) @@ -170,6 +213,38 @@ public sealed class BackupService public async Task RestoreAsync( string zipPath, string targetDir, RestoreOptions options, CancellationToken ct = default) + { + if (!LicenseBackupCipher.IsEncrypted(zipPath)) + return await RestoreZipAsync(zipPath, targetDir, options, ct); + + if (string.IsNullOrWhiteSpace(options.LicenseKey)) + throw new BackupKeyMismatchException( + "Diese Sicherung ist verschlüsselt; zum Zurückspielen wird ein Lizenzschlüssel gebraucht."); + + // Die Probe zuerst: Passt der Schluessel nicht, soll das vor dem Auspacken + // feststehen und nicht mitten darin. + if (!LicenseBackupCipher.MatchesKey(zipPath, options.LicenseKey)) + throw new BackupKeyMismatchException( + "Diese Sicherung wurde mit einem anderen Lizenzschlüssel verschlüsselt."); + + var klarZip = Path.Combine(Path.GetTempPath(), "clawd-restore-" + Guid.NewGuid().ToString("N") + ".zip"); + try + { + LicenseBackupCipher.DecryptTo(zipPath, klarZip, options.LicenseKey); + + return await RestoreZipAsync(klarZip, targetDir, options with + { + Passphrase = LicenseBackupCipher.NormalizeKey(options.LicenseKey) + }, ct); + } + finally + { + TryDeleteFile(klarZip); + } + } + + private async Task RestoreZipAsync( + string zipPath, string targetDir, RestoreOptions options, CancellationToken ct) { var manifest = await InspectAsync(zipPath, ct); @@ -260,9 +335,18 @@ public sealed class BackupService return null; } - return options.Secrets == SecretMode.Passphrase - ? PassphraseProtector.Protect(plain, options.Passphrase!) - : null; + // Bei License tragen die Werte innen zusaetzlich die Passphrase-Schicht, mit dem + // Lizenzschluessel als Passphrase. Doppelt, aber mit Absicht: So bleibt der + // erprobte Weg zurueck (RewriteSecretsForRestore) derselbe, und ein + // entschluesseltes Archiv, das jemand liegen laesst, enthaelt trotzdem keinen + // Klartext. + return options.Secrets switch + { + SecretMode.Passphrase => PassphraseProtector.Protect(plain, options.Passphrase!), + SecretMode.License => PassphraseProtector.Protect( + plain, LicenseBackupCipher.NormalizeKey(options.LicenseKey!)), + _ => null + }; }); return (rewritten, count); @@ -386,6 +470,13 @@ public sealed class BackupService return full; } + private static void TryDeleteFile(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (IOException) { /* Aufraeumen darf den eigentlichen Vorgang nicht umwerfen. */ } + catch (UnauthorizedAccessException) { } + } + private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } diff --git a/src/ClawdDotNet.Core/Backup/LicenseBackupCipher.cs b/src/ClawdDotNet.Core/Backup/LicenseBackupCipher.cs new file mode 100644 index 0000000..0221c99 --- /dev/null +++ b/src/ClawdDotNet.Core/Backup/LicenseBackupCipher.cs @@ -0,0 +1,305 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace ClawdDotNet.Core.Backup; + +/// Die Sicherung wurde mit einem anderen Lizenzschlüssel verschlüsselt. +public sealed class BackupKeyMismatchException(string message) : BackupException(message); + +/// +/// Verschlüsselt eine ganze Sicherung mit dem Lizenzschlüssel der Installation. +/// +/// Warum der Lizenzschlüssel. Er ist der eine Wert, den jede Installation +/// ohnehin hat und den ihr Betreiber ohnehin kennt. Eine eigene Passphrase müsste +/// irgendwo gespeichert werden — und neben den Sicherungen abgelegt wäre sie wirkungslos; +/// genau deshalb sicherte die tägliche Sicherung bisher gar keine Zugangsdaten. Der +/// Lizenzschlüssel liegt im Manager, nicht bei den Archiven. +/// +/// Was das aushält und was nicht. Wer Lizenzschlüssel und Archiv hat, hat +/// alle Zugangsdaten darin. Der Schlüssel ist dem Hersteller bekannt, und er gilt für +/// alle Instanzen einer Installation. Das ist die bewusste Abwägung: Es geht darum, dass +/// ein Archiv auf einem FTP-Server oder einem USB-Stick niemandem nützt, der es nur +/// findet — nicht darum, jemanden mit Schlüssel und Absicht aufzuhalten. +/// +/// Aufbau. Kopf (Kennung, Fassung, Schlüsselprobe), dann das Manifest als +/// eigener verschlüsselter Abschnitt, dann das ZIP in Stücken zu 1 MiB, jedes mit +/// AES-256-GCM und eigener Nonce. Die Stückelung, weil eine Sicherung mit Protokollen +/// Hunderte Megabyte haben kann und AES-GCM in einem Zug alles im Speicher bräuchte. Das +/// Manifest vorn, damit die Liste der Sicherungen nicht jedes Archiv ganz entschlüsseln +/// muss. Die Schlüsselprobe, damit „falscher Lizenzschlüssel" von „beschädigt" zu +/// unterscheiden ist, bevor irgendetwas angehalten wird. +/// +/// Kein ZIP mit Kennwort, kein RAR. RAR zu schreiben braucht das +/// lizenzpflichtige Programm des Herstellers. Ein ZIP mit Kennwort kann .NET nicht +/// schreiben, und die verbreitete Variante (ZipCrypto) ist gebrochen. Weil der +/// eigentliche Schlüssel ohnehin abgeleitet wird, ließe sich ein solches ZIP auch mit +/// 7-Zip nicht öffnen — die Verträglichkeit brächte nichts. +/// +public static class LicenseBackupCipher +{ + /// Die Endung verschlüsselter Sicherungen. + public const string Extension = ".clawdbak"; + + private static readonly byte[] Magic = "CLAWDBAK"u8.ToArray(); + private const byte FormatVersion = 1; + + /// + /// Der feste Salt. Er steht im Code und ist damit in jeder Programmdatei — das ist + /// gewollt: Nur so ergibt derselbe Lizenzschlüssel auf jeder Maschine denselben + /// Schlüssel, und nur so lässt sich eine Sicherung anderswo öffnen. + /// + private static readonly byte[] FixedSalt = "ClawdDotNet.Backup.Lizenz.v1/7f3c9a51"u8.ToArray(); + + private const int Iterations = 210_000; + private const int KeySize = 32; + private const int NonceSize = 12; + private const int TagSize = 16; + private const int KeyCheckSize = 16; + private const int ChunkSize = 1024 * 1024; + + private static readonly byte[] KeyCheckLabel = "clawd-backup-keycheck-v1"u8.ToArray(); + private static readonly byte[] ManifestAad = "clawd-backup-manifest-v1"u8.ToArray(); + + /// + /// Abgeleitete Schlüssel je Lizenzschlüssel. PBKDF2 kostet mit Absicht Zeit — die + /// Liste von vierzehn Sicherungen soll sie nicht vierzehnmal zahlen. + /// + private static readonly ConcurrentDictionary Keys = new(); + + /// + /// Bringt einen Lizenzschlüssel in eine feste Form. + /// + /// Im Wiederherstellungsdialog tippt ein Mensch den Schlüssel einer anderen + /// Installation ab. Ein Leerzeichen am Ende oder ein kleiner Buchstabe dürfen dort nicht + /// den Unterschied zwischen „geht" und „verloren" machen. + /// + public static string NormalizeKey(string licenseKey) + => new string(licenseKey.Where(c => !char.IsWhiteSpace(c)).ToArray()).ToUpperInvariant(); + + /// Ist diese Datei eine verschlüsselte Sicherung? + public static bool IsEncrypted(string path) + { + try + { + using var datei = File.OpenRead(path); + Span kopf = stackalloc byte[8]; + return datei.Read(kopf) == 8 && kopf.SequenceEqual(Magic); + } + catch (IOException) { return false; } + } + + /// + /// Passt dieser Lizenzschlüssel zu der Sicherung? Liest nur den Kopf. + /// + /// Der Grund, warum es diese Probe gibt: Die Wiederherstellung hält die Instanz + /// an. Wer erst anhält und dann merkt, dass der Schlüssel nicht passt, hat eine laufende + /// Instanz für nichts gestoppt. + /// + public static bool MatchesKey(string path, string licenseKey) + { + using var datei = File.OpenRead(path); + var probe = ReadHeader(datei); + return CryptographicOperations.FixedTimeEquals(probe, KeyCheck(Derive(licenseKey))); + } + + /// Verschlüsselt ein fertiges ZIP samt Manifest in die Zieldatei. + public static void Encrypt(string zipPath, string manifestJson, string targetPath, string licenseKey) + { + if (string.IsNullOrWhiteSpace(licenseKey)) + throw new BackupException( + "Ohne Lizenzschlüssel lässt sich keine verschlüsselte Sicherung anlegen."); + + var key = Derive(licenseKey); + + using var aus = File.Create(targetPath); + aus.Write(Magic); + aus.WriteByte(FormatVersion); + aus.Write(KeyCheck(key)); + + using var aes = new AesGcm(key, TagSize); + + WriteSection(aus, aes, Encoding.UTF8.GetBytes(manifestJson), ManifestAad); + + using var ein = File.OpenRead(zipPath); + var puffer = new byte[ChunkSize]; + var naechster = new byte[ChunkSize]; + long index = 0; + + var gelesen = ReadFull(ein, puffer); + + // Immer mindestens ein Stück, auch bei leerem Inhalt: Das letzte Stück traegt die + // Endmarke, und ohne sie liesse sich ein abgeschnittenes Archiv nicht erkennen. + while (true) + { + var folgend = gelesen == ChunkSize ? ReadFull(ein, naechster) : 0; + var letztes = folgend == 0; + + WriteSection(aus, aes, puffer.AsSpan(0, gelesen), DataAad(index, letztes)); + + if (letztes) break; + + (puffer, naechster) = (naechster, puffer); + gelesen = folgend; + index++; + } + } + + /// Liest das Manifest, ohne das Archiv zu entschlüsseln. + /// Anderer Lizenzschlüssel. + public static string ReadManifest(string path, string licenseKey) + { + using var datei = File.OpenRead(path); + var key = Open(datei, licenseKey); + + using var aes = new AesGcm(key, TagSize); + return Encoding.UTF8.GetString(ReadSection(datei, aes, ManifestAad)); + } + + /// Entschlüsselt das Archiv in eine ZIP-Datei. + /// Anderer Lizenzschlüssel. + public static void DecryptTo(string path, string zipTargetPath, string licenseKey) + { + using var datei = File.OpenRead(path); + var key = Open(datei, licenseKey); + + using var aes = new AesGcm(key, TagSize); + ReadSection(datei, aes, ManifestAad); // ueberspringen, aber pruefen + + using var aus = File.Create(zipTargetPath); + + for (long index = 0; ; index++) + { + // Zuerst mit Endmarke versuchen, dann ohne. Welches passt, sagt der Tag - und + // nur so wird ein Archiv erkannt, dem hinten Stuecke fehlen. + var start = datei.Position; + + if (TryReadSection(datei, aes, DataAad(index, letztes: true)) is { } schluss) + { + aus.Write(schluss); + + if (datei.Position != datei.Length) + throw new BackupException("Nach dem letzten Abschnitt folgen noch Daten — die Sicherung ist beschädigt."); + + return; + } + + datei.Position = start; + aus.Write(ReadSection(datei, aes, DataAad(index, letztes: false))); + } + } + + // ─── Innereien ─── + + private static byte[] Derive(string licenseKey) + => Keys.GetOrAdd(NormalizeKey(licenseKey), k => Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(k), FixedSalt, Iterations, HashAlgorithmName.SHA256, KeySize)); + + private static byte[] KeyCheck(byte[] key) + => HMACSHA256.HashData(key, KeyCheckLabel)[..KeyCheckSize]; + + private static byte[] ReadHeader(Stream datei) + { + Span magic = stackalloc byte[8]; + if (datei.Read(magic) != 8 || !magic.SequenceEqual(Magic)) + throw new BackupException("Das ist keine verschlüsselte ClawdDotNet-Sicherung."); + + var version = datei.ReadByte(); + if (version != FormatVersion) + throw new BackupException($"Unbekannte Fassung {version} der Sicherung — sie stammt von einer neueren Programmversion."); + + var probe = new byte[KeyCheckSize]; + if (ReadFull(datei, probe) != KeyCheckSize) + throw new BackupException("Die Sicherung ist abgeschnitten."); + + return probe; + } + + private static byte[] Open(Stream datei, string licenseKey) + { + var probe = ReadHeader(datei); + var key = Derive(licenseKey); + + if (!CryptographicOperations.FixedTimeEquals(probe, KeyCheck(key))) + { + throw new BackupKeyMismatchException( + "Diese Sicherung wurde mit einem anderen Lizenzschlüssel verschlüsselt. " + + "Zum Öffnen wird der Lizenzschlüssel der Installation gebraucht, von der sie stammt."); + } + + return key; + } + + private static byte[] DataAad(long index, bool letztes) + { + var aad = new byte[4 + 8 + 1]; + "data"u8.CopyTo(aad); + BinaryPrimitives.WriteInt64LittleEndian(aad.AsSpan(4), index); + aad[12] = letztes ? (byte)1 : (byte)0; + return aad; + } + + private static void WriteSection(Stream aus, AesGcm aes, ReadOnlySpan klar, byte[] aad) + { + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var geheim = new byte[klar.Length]; + var tag = new byte[TagSize]; + + aes.Encrypt(nonce, klar, geheim, tag, aad); + + Span laenge = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(laenge, geheim.Length); + + aus.Write(nonce); + aus.Write(laenge); + aus.Write(tag); + aus.Write(geheim); + } + + private static byte[] ReadSection(Stream ein, AesGcm aes, byte[] aad) + => TryReadSection(ein, aes, aad) + ?? throw new BackupException("Die Sicherung ist beschädigt oder wurde verändert."); + + private static byte[]? TryReadSection(Stream ein, AesGcm aes, byte[] aad) + { + var nonce = new byte[NonceSize]; + var laenge = new byte[4]; + var tag = new byte[TagSize]; + + if (ReadFull(ein, nonce) != NonceSize || ReadFull(ein, laenge) != 4 || ReadFull(ein, tag) != TagSize) + throw new BackupException("Die Sicherung ist abgeschnitten."); + + var n = BinaryPrimitives.ReadInt32LittleEndian(laenge); + if (n < 0 || n > ChunkSize * 64) + throw new BackupException("Die Sicherung ist beschädigt."); + + var geheim = new byte[n]; + if (ReadFull(ein, geheim) != n) + throw new BackupException("Die Sicherung ist abgeschnitten."); + + var klar = new byte[n]; + + try + { + aes.Decrypt(nonce, geheim, tag, klar, aad); + return klar; + } + catch (AuthenticationTagMismatchException) + { + return null; + } + } + + private static int ReadFull(Stream ein, byte[] puffer) + { + var gesamt = 0; + while (gesamt < puffer.Length) + { + var n = ein.Read(puffer, gesamt, puffer.Length - gesamt); + if (n == 0) break; + gesamt += n; + } + return gesamt; + } +} diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 371435d..1985967 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -34,23 +34,45 @@ public sealed record BackupItemDto( string InstanceName, string Secrets, int SecretCount, - int FileCount); + 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. +/// +/// 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( - string? Secrets, - string? Passphrase, 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? 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); /// /// 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(); + var liste = new List(); - 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)); - /// - /// Wo die Sicherungen dieser Instanz liegen. - /// - /// Eine unlesbare Instanzdatei wird gemeldet, nicht verschluckt. Sonst - /// landeten die Sicherungen wortlos im Standardverzeichnis statt im eingestellten — - /// und niemand sähe, warum die alten verschwunden sind. Genau diese Bauart hat - /// manager.json schon einmal eine halbe Stunde Fehlersuche gekostet. - /// - 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( - 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); } diff --git a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs index 5212aef..6792d76 100644 --- a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs +++ b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs @@ -54,7 +54,7 @@ public sealed class ManagerWebHost : IAsyncDisposable /// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging, /// steht im Protokoll. /// - public static ManagerWebHost? TryStart(ManagerCore manager, ILoggerFactory loggerFactory) + public static ManagerWebHost? TryStart(ManagerCore manager, ManagerBackups backups, ILoggerFactory loggerFactory) { var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web"); var settings = manager.Settings; @@ -82,7 +82,7 @@ public sealed class ManagerWebHost : IAsyncDisposable try { - var app = Build(manager, loggerFactory, url, token, forwarding); + var app = Build(manager, backups, loggerFactory, url, token, forwarding); app.StartAsync().GetAwaiter().GetResult(); @@ -104,6 +104,7 @@ public sealed class ManagerWebHost : IAsyncDisposable private static WebApplication Build( ManagerCore manager, + ManagerBackups backups, ILoggerFactory loggerFactory, string url, string token, @@ -128,6 +129,7 @@ public sealed class ManagerWebHost : IAsyncDisposable builder.Services.AddSingleton(manager); builder.Services.AddSingleton(manager.Instances); + builder.Services.AddSingleton(backups); builder.Services.AddSingleton(AccessToken.For(token)); builder.Services.AddSingleton(forwarding); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js index e26d932..306aa80 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js @@ -151,14 +151,18 @@ export async function api(pfad, optionen = {}) { if (!antwort.ok) { let meldung = `HTTP ${antwort.status}`; + let code = null; try { const rumpf = await antwort.json(); meldung = rumpf?.message ?? rumpf?.meldung ?? rumpf?.fehler ?? rumpf?.detail ?? meldung; + code = rumpf?.code ?? null; } catch { // Wenn kein JSON geliefert wird, bleibt es beim HTTP-Code } const fehler = new Error(meldung); fehler.status = antwort.status; + // Maschinenlesbarer Grund, wo der Server einen mitschickt (etwa "fremde_lizenz"). + fehler.code = code; if (antwort.status === 402) fehler.lizenzFehlt = true; if (antwort.status === 409) fehler.instanzKonflikt = true; if (antwort.status === 502) fehler.instanzUnerreichbar = true; diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js index 4aa77ce..d2944fd 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js @@ -1,10 +1,9 @@ -// ClawdDotNet — Datensicherung & Wiederherstellung (Vorhaben #530, Stufe 6) +// ClawdDotNet — Datensicherung & Wiederherstellung (#530) // -// Verwaltet Sicherungen (.zip) der aktiven Instanz: -// - Auflistung aller Archive im Sicherungsverzeichnis -// - Neue Sicherung anlegen (Passphrase-Schutz, Logs, Chatverlauf) -// - Wiederherstellung (mit geordnetem Stop/Start der Instanz) -// - Löschen von Archiven +// Jede Sicherung, die der Instanzmanager anlegt, ist als Ganzes mit seinem +// Lizenzschlüssel verschlüsselt (.clawdbak). Stammt eine Sicherung von einer anderen +// Installation, fragt die Wiederherstellung nach deren Lizenzschlüssel. +// Dazu der optionale FTP-Upload. // Strikt DOM API, kein innerHTML. import { api, holeAktiveInstanz } from "../modules/api.js"; @@ -28,50 +27,66 @@ function formatiereBytes(bytes) { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; } +/** Was eine Sicherung schützt — als Plakette. */ +function schutzPlakette(b) { + if (b.fremdeLizenz) return erstellePlakette("Fremde Lizenz", "warnung"); + if (b.secrets === "Unlesbar") return erstellePlakette("Unlesbar", "gefahr"); + if (b.verschluesselt) return erstellePlakette(`Lizenz (${b.secretCount} Zugangsdaten)`, "erfolg"); + if (b.secrets === "Passphrase") return erstellePlakette(`Passphrase (${b.secretCount})`, "neutral"); + return erstellePlakette("Unverschlüsselt, ohne Zugangsdaten", "neutral"); +} + +function checkbox(id, text, an) { + const gruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" }); + const box = erstelleElement("input", { type: "checkbox", id, checked: an }); + gruppe.append(box, erstelleElement("label", { for: id, text })); + return { gruppe, box }; +} + +function feld(label, eingabe, hilfe) { + const gruppe = erstelleElement("div", { klasse: "form-gruppe" }); + gruppe.append(erstelleElement("label", { text: label }), eingabe); + if (hilfe) gruppe.append(erstelleElement("p", { klasse: "form-hilfe", text: hilfe })); + return gruppe; +} + export async function rendereSicherung() { const container = document.getElementById("ansicht-inhalt"); if (!container) return; container.replaceChildren(); const instanzId = holeAktiveInstanz(); + const pfadInstanz = encodeURIComponent(instanzId || ""); const layout = erstelleElement("div", { klasse: "sicherung-ansicht" }); - // Kopfzeile + // ─── Kopf ─── const kopf = erstelleElement("div", { klasse: "ansicht-kopf" }); - const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" }); - titelGruppe.append(erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" })); - - const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" }); - - const aktualisierenBtn = erstelleElement( - "button", - { - klasse: "schaltflaeche flach", - title: "Sicherungen neu laden", - onclick: () => ladeSicherungen() - }, - erstelleIcon(PFADE.aktualisieren, 16), - erstelleElement("span", { text: " Aktualisieren" }) + kopf.append( + erstelleElement("div", { klasse: "gruppe-horizontal" }, + erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" })), + erstelleElement("div", { klasse: "gruppe-horizontal" }, + erstelleElement("button", { + klasse: "schaltflaeche flach", + title: "Sicherungen neu laden", + disabled: !instanzId, + onclick: () => ladeSicherungen() + }, erstelleIcon(PFADE.aktualisieren, 16), erstelleElement("span", { text: " Aktualisieren" })), + erstelleElement("button", { + klasse: "schaltflaeche primaer", + title: "Neue Sicherung erstellen", + disabled: !instanzId, + onclick: () => oeffneSicherungsDialog() + }, erstelleIcon(PFADE.plus, 16), erstelleElement("span", { text: " Neue Sicherung" })) + ) ); - - const neuBtn = erstelleElement( - "button", - { - klasse: "schaltflaeche primaer", - title: "Neue Sicherung erstellen", - disabled: !instanzId, - onclick: () => oeffneSicherungsDialog() - }, - erstelleIcon(PFADE.plus, 16), - erstelleElement("span", { text: " Neue Sicherung" }) - ); - - aktionenGruppe.append(aktualisierenBtn, neuBtn); - kopf.append(titelGruppe, aktionenGruppe); layout.append(kopf); + const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" }); + const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" }); + const ftpBereich = erstelleElement("div", { klasse: "sicherung-ftp-bereich" }); + if (!instanzId) { - layout.append( + inhaltBereich.append( erstelleKarte("Keine Instanz ausgewählt", [ erstelleElement("p", { klasse: "hinweis", @@ -79,34 +94,37 @@ export async function rendereSicherung() { }) ]) ); - container.append(layout); - return; } - // Verzeichnis-Info & Inhalt - const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" }); - const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" }); - - layout.append(infoBereich, inhaltBereich); + layout.append(infoBereich, inhaltBereich, ftpBereich); container.append(layout); + // Der FTP-Upload gilt für alle Instanzen und ist deshalb auch ohne Auswahl da. + rendereFtp(ftpBereich); + + if (!instanzId) return; + + // ─── Liste ─── async function ladeSicherungen() { inhaltBereich.replaceChildren( erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Sicherungen…" }) ); try { - const daten = await api(`/verwaltung/instanzen/${instanzId}/backups`); - const verzeichnis = daten?.directory ?? "—"; + const daten = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`); const backups = daten?.backups ?? []; infoBereich.replaceChildren( erstelleKarte("Sicherungsverzeichnis", [ - erstelleElement("p", { klasse: "gedaempft", text: "Sicherungen werden komprimiert als ZIP-Archiv abgelegt. Die Datenbank (state.db) wird konsistent per VACUUM INTO kopiert." }), + erstelleElement("p", { + klasse: "gedaempft", + text: "Sicherungen werden als Ganzes mit dem Lizenzschlüssel des Instanzmanagers " + + "verschlüsselt und enthalten die Zugangsdaten. Auf einer anderen Maschine lassen " + + "sie sich mit demselben Lizenzschlüssel zurückspielen." + }), erstelleElement("div", { klasse: "ordner-pfad-anzeige" }, erstelleIcon(PFADE.server, 14), - erstelleElement("code", { text: verzeichnis }) - ) + erstelleElement("code", { text: daten?.directory ?? "—" })) ]) ); @@ -115,86 +133,53 @@ export async function rendereSicherung() { erstelleKarte("Vorhandene Sicherungen", [ erstelleElement("p", { klasse: "gedaempft", - text: "Für diese Instanz sind noch keine Sicherungsarchive vorhanden. Erstelle über die Schaltfläche oben eine neue Sicherung." + text: "Für diese Instanz gibt es noch keine Sicherung. Lege über die Schaltfläche oben eine an." }) ]) ); return; } - const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" }); - const thead = erstelleElement("thead", {}, - erstelleElement("tr", {}, - erstelleElement("th", { text: "Archivdatei" }), - erstelleElement("th", { text: "Erstellt am" }), - erstelleElement("th", { text: "Größe" }), - erstelleElement("th", { text: "Zugangsdaten" }), - erstelleElement("th", { text: "Dateien" }), - erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" }) - ) - ); - const tbody = erstelleElement("tbody"); for (const b of backups) { - const tr = erstelleElement("tr"); - - // Dateiname & Icon - const dateiTd = erstelleElement("td", { klasse: "dateiname-zelle" }, - erstelleIcon(PFADE.sicherung, 16), - erstelleElement("strong", { text: b.fileName }) - ); - - // Datum - const datumTd = erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) }); - - // Größe - const groesseTd = erstelleElement("td", { text: formatiereBytes(b.sizeBytes) }); - - // Zugangsdaten Modus - const hatPassphrase = b.secrets === "Passphrase"; - const secretsPlakette = erstellePlakette( - hatPassphrase ? `Passphrase (${b.secretCount})` : "Ausgeschlossen", - hatPassphrase ? "erfolg" : "neutral" - ); - const secretsTd = erstelleElement("td", {}, secretsPlakette); - - // Dateienanzahl - const dateienTd = erstelleElement("td", { text: b.fileCount > 0 ? `${b.fileCount} Dateien` : "—" }); - - // Aktionen - const aktionenTd = erstelleElement("td", { klasse: "spalte-aktionen" }); - - const restoreBtn = erstelleElement( - "button", - { + const aktionen = erstelleElement("td", { klasse: "spalte-aktionen" }, + erstelleElement("button", { klasse: "schaltflaeche klein sekundär", title: "Diese Sicherung wiederherstellen", + disabled: b.secrets === "Unlesbar", onclick: () => oeffneRestoreDialog(b) - }, - erstelleIcon(PFADE.abspielen, 14), - erstelleElement("span", { text: " Wiederherstellen" }) - ); - - const deleteBtn = erstelleElement( - "button", - { + }, erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " Wiederherstellen" })), + erstelleElement("button", { klasse: "schaltflaeche klein gefahr flach", title: "Sicherungsdatei unwiderruflich löschen", onclick: () => oeffneLoeschenDialog(b.fileName) - }, - erstelleIcon(PFADE.muelleimer, 14) + }, erstelleIcon(PFADE.muelleimer, 14)) ); - aktionenTd.append(restoreBtn, deleteBtn); - tr.append(dateiTd, datumTd, groesseTd, secretsTd, dateienTd, aktionenTd); - tbody.append(tr); + tbody.append(erstelleElement("tr", {}, + erstelleElement("td", { klasse: "dateiname-zelle" }, + erstelleIcon(PFADE.sicherung, 16), erstelleElement("strong", { text: b.fileName })), + erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) }), + erstelleElement("td", { text: formatiereBytes(b.sizeBytes) }), + erstelleElement("td", {}, schutzPlakette(b)), + erstelleElement("td", { text: b.fileCount > 0 ? `${b.fileCount} Dateien` : "—" }), + aktionen + )); } - tabelle.append(thead, tbody); - inhaltBereich.replaceChildren( - erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle]) - ); + const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" }, + erstelleElement("thead", {}, + erstelleElement("tr", {}, + erstelleElement("th", { text: "Archivdatei" }), + erstelleElement("th", { text: "Erstellt am" }), + erstelleElement("th", { text: "Größe" }), + erstelleElement("th", { text: "Schutz" }), + erstelleElement("th", { text: "Dateien" }), + erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" }))), + tbody); + + inhaltBereich.replaceChildren(erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle])); } catch (err) { inhaltBereich.replaceChildren( erstelleKarte("Fehler", [ @@ -204,92 +189,40 @@ export async function rendereSicherung() { } } - // ─── Modal: Neue Sicherung erstellen ─── + // ─── Neue Sicherung ─── function oeffneSicherungsDialog() { const form = erstelleElement("form", { klasse: "formular" }); + const chats = checkbox("backup-chk-chats", "Chatverläufe sichern", true); + const logs = checkbox("backup-chk-logs", "Protokolldateien (Logs) einschließen", false); - // Modus Zugangsdaten - const secretsGruppe = erstelleElement("div", { klasse: "form-gruppe" }); - secretsGruppe.append( - erstelleElement("label", { text: "Zugangsdaten & Schlüssel" }), - erstelleElement("p", { klasse: "form-hilfe", text: "DPAPI-geschützte Daten können mit einer Passphrase geschützt oder sicherheitshalber entfernt werden." }) + form.append( + erstelleElement("p", { + klasse: "form-hilfe", + text: "Die Sicherung wird mit dem Lizenzschlüssel des Instanzmanagers verschlüsselt und " + + "enthält die Zugangsdaten. Ist ein FTP-Upload eingerichtet, wird sie anschließend hochgeladen." + }), + chats.gruppe, + logs.gruppe ); - const secretsSelect = erstelleElement("select", { klasse: "eingabe" }); - const optExclude = erstelleElement("option", { value: "Exclude", text: "Entfernen (gefahrlos ablegbar, Schlüssel müssen neu eingetragen werden)" }); - const optPassphrase = erstelleElement("option", { value: "Passphrase", text: "Mit Passphrase schützen (vollständig übertragbar)" }); - secretsSelect.append(optExclude, optPassphrase); - secretsGruppe.append(secretsSelect); - - // Passphrase Feld - const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { display: "none" } }); - const passInput = erstelleElement("input", { - type: "password", - klasse: "eingabe", - placeholder: "Passphrase für Verschlüsselung eingeben…" - }); - passGruppe.append( - erstelleElement("label", { text: "Passphrase" }), - passInput - ); - - secretsSelect.addEventListener("change", () => { - passGruppe.style.display = secretsSelect.value === "Passphrase" ? "block" : "none"; - if (secretsSelect.value === "Passphrase") passInput.focus(); - }); - - // Checkboxen - const chatsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" }); - const chatsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-chats", checked: true }); - const chatsLabel = erstelleElement("label", { for: "backup-chk-chats", text: "Chatverläufe sichern" }); - chatsGruppe.append(chatsCheckbox, chatsLabel); - - const logsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" }); - const logsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-logs", checked: false }); - const logsLabel = erstelleElement("label", { for: "backup-chk-logs", text: "Protokolldateien (Logs) einschließen" }); - logsGruppe.append(logsCheckbox, logsLabel); - - form.append(secretsGruppe, passGruppe, chatsGruppe, logsGruppe); - let modal; - const abbrechenBtn = erstelleElement("button", { - type: "button", - klasse: "schaltflaeche", - text: "Abbrechen", - onclick: () => modal.schliessen() - }); - const speichernBtn = erstelleElement("button", { - type: "submit", - klasse: "schaltflaeche primaer", - text: "Sicherung erstellen" + type: "submit", klasse: "schaltflaeche primaer", text: "Sicherung erstellen" }); form.addEventListener("submit", async (e) => { e.preventDefault(); - const secrets = secretsSelect.value; - const passphrase = passInput.value.trim(); - - if (secrets === "Passphrase" && !passphrase) { - zeigeMeldung("Bitte gib eine Passphrase ein.", "warnung"); - passInput.focus(); - return; - } - speichernBtn.disabled = true; speichernBtn.textContent = "Erstelle Sicherung…"; try { - const res = await api(`/verwaltung/instanzen/${instanzId}/backups`, { + const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`, { method: "POST", - body: { - secrets, - passphrase: passphrase || null, - includeLogs: logsCheckbox.checked, - includeChatHistory: chatsCheckbox.checked - } + body: { includeLogs: logs.box.checked, includeChatHistory: chats.box.checked } }); - zeigeMeldung(res.meldung || "Sicherung erfolgreich erstellt.", "erfolg"); + // Ein gescheiterter Upload steht in der Meldung; die Sicherung liegt trotzdem lokal. + const warnt = (res.meldung || "").includes("gescheitert"); + zeigeMeldung(res.meldung || "Sicherung erstellt.", warnt ? "warnung" : "erfolg", warnt ? 8000 : undefined); modal.schliessen(); ladeSicherungen(); } catch (err) { @@ -299,15 +232,27 @@ export async function rendereSicherung() { } }); - modal = erstelleModal("Neue Sicherung erstellen", form, [abbrechenBtn, speichernBtn]); + modal = erstelleModal("Neue Sicherung erstellen", form, [ + erstelleElement("button", { + type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen() + }), + speichernBtn + ]); modal.oeffnen(); } - // ─── Modal: Wiederherstellen ─── + // ─── Wiederherstellen ─── + async function stelleWiederHer(backup, { passphrase = null, lizenzschluessel = null } = {}) { + return api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(backup.fileName)}/restore`, { + method: "POST", + body: { passphrase, lizenzschluessel } + }); + } + function oeffneRestoreDialog(backup) { const form = erstelleElement("form", { klasse: "formular" }); - const warnung = erstelleElement("div", { klasse: "hinweis-box warnung" }, + form.append(erstelleElement("div", { klasse: "hinweis-box warnung" }, erstelleIcon(PFADE.ausrufezeichen, 18), erstelleElement("div", {}, erstelleElement("strong", { text: "Achtung: Überschreiben bestehender Daten" }), @@ -315,50 +260,30 @@ export async function rendereSicherung() { text: `Die Wiederherstellung überschreibt die aktuellen Konfigurationen und Daten der Instanz mit dem Stand vom ${formatiereDatumZeit(backup.createdAt)}.` }), erstelleElement("p", { - text: "Die Instanz wird dafür kurz geordnet gestoppt und nach erfolgreicher Rücksicherung wieder hochgefahren." - }) - ) - ); - - form.append(warnung); + text: "Die Instanz wird dafür geordnet angehalten und danach wieder gestartet." + })))); + // Nur ältere, unverschlüsselte Sicherungen mit Passphrase brauchen sie noch. let passInput = null; - if (backup.secrets === "Passphrase") { - const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { marginTop: "16px" } }); + if (backup.secrets === "Passphrase" && !backup.verschluesselt) { passInput = erstelleElement("input", { - type: "password", - klasse: "eingabe", - placeholder: "Passphrase der Sicherung eingeben…", - required: true + type: "password", klasse: "eingabe", placeholder: "Passphrase der Sicherung…", required: true }); - passGruppe.append( - erstelleElement("label", { text: "Passphrase für Zugangsdaten" }), - passInput - ); - form.append(passGruppe); + form.append(feld("Passphrase für Zugangsdaten", passInput)); } let modal; - const abbrechenBtn = erstelleElement("button", { - type: "button", - klasse: "schaltflaeche", - text: "Abbrechen", - onclick: () => modal.schliessen() - }); - const restoreBtn = erstelleElement("button", { - type: "submit", - klasse: "schaltflaeche warnung", - text: "Jetzt Wiederherstellen" + type: "submit", klasse: "schaltflaeche warnung", text: "Jetzt wiederherstellen" }); form.addEventListener("submit", async (e) => { e.preventDefault(); const passphrase = passInput?.value?.trim() || null; - if (backup.secrets === "Passphrase" && !passphrase) { + if (passInput && !passphrase) { zeigeMeldung("Bitte gib die Passphrase an.", "warnung"); - passInput?.focus(); + passInput.focus(); return; } @@ -366,38 +291,94 @@ export async function rendereSicherung() { restoreBtn.textContent = "Stelle wieder her…"; try { - const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(backup.fileName)}/restore`, { - method: "POST", - body: { passphrase } - }); - zeigeMeldung(res.meldung || "Wiederherstellung erfolgreich abgeschlossen.", "erfolg"); + const res = await stelleWiederHer(backup, { passphrase }); + zeigeMeldung(res.meldung || "Wiederherstellung abgeschlossen.", "erfolg"); modal.schliessen(); ladeSicherungen(); } catch (err) { + if (err.code === "fremde_lizenz") { + // Der eigene Schlüssel passt nicht - die Sicherung stammt von einer anderen + // Installation. Angehalten wurde dafür nichts; das prüft der Server vorher. + modal.schliessen(); + frageLizenzschluessel(backup, err.message); + return; + } zeigeMeldung(`Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr"); restoreBtn.disabled = false; - restoreBtn.textContent = "Jetzt Wiederherstellen"; + restoreBtn.textContent = "Jetzt wiederherstellen"; } }); - modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [abbrechenBtn, restoreBtn]); + modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [ + erstelleElement("button", { + type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen() + }), + restoreBtn + ]); modal.oeffnen(); } - // ─── Modal: Löschen ─── - function oeffneLoeschenDialog(fileName) { - const text = erstelleElement("p", { - text: `Soll die Sicherungsdatei '${fileName}' wirklich unwiderruflich gelöscht werden?` + /** + * Das Fenster, das nach dem Lizenzschlüssel der Quellinstallation fragt. + * Erscheint, wenn der eigene Schlüssel die Sicherung nicht öffnet. + */ + function frageLizenzschluessel(backup, grund) { + const form = erstelleElement("form", { klasse: "formular" }); + const eingabe = erstelleElement("input", { + type: "password", + klasse: "eingabe", + placeholder: "Lizenzschlüssel der Installation, von der die Sicherung stammt…", + autocomplete: "off", + required: true }); + form.append( + erstelleElement("p", { text: grund }), + feld("Lizenzschlüssel der Quelle", eingabe, + "Groß- und Kleinschreibung sowie Leerzeichen spielen keine Rolle.") + ); + let modal; - const abbrechenBtn = erstelleElement("button", { - type: "button", - klasse: "schaltflaeche", - text: "Abbrechen", - onclick: () => modal.schliessen() + const okBtn = erstelleElement("button", { + type: "submit", klasse: "schaltflaeche warnung", text: "Mit diesem Schlüssel wiederherstellen" }); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const lizenzschluessel = eingabe.value.trim(); + if (!lizenzschluessel) { eingabe.focus(); return; } + + okBtn.disabled = true; + okBtn.textContent = "Stelle wieder her…"; + + try { + const res = await stelleWiederHer(backup, { lizenzschluessel }); + zeigeMeldung(res.meldung || "Wiederherstellung abgeschlossen.", "erfolg"); + modal.schliessen(); + ladeSicherungen(); + } catch (err) { + zeigeMeldung(err.code === "fremde_lizenz" + ? "Dieser Lizenzschlüssel passt nicht zu der Sicherung." + : `Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr"); + okBtn.disabled = false; + okBtn.textContent = "Mit diesem Schlüssel wiederherstellen"; + eingabe.select(); + } + }); + + modal = erstelleModal("Lizenzschlüssel der Quelle erforderlich", form, [ + erstelleElement("button", { + type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen() + }), + okBtn + ]); + modal.oeffnen(); + eingabe.focus(); + } + + // ─── Löschen ─── + function oeffneLoeschenDialog(fileName) { + let modal; const loeschenBtn = erstelleElement("button", { type: "button", klasse: "schaltflaeche gefahr", @@ -405,7 +386,7 @@ export async function rendereSicherung() { onclick: async () => { loeschenBtn.disabled = true; try { - const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(fileName)}`, { + const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(fileName)}`, { method: "DELETE" }); zeigeMeldung(res.meldung || "Sicherung gelöscht.", "erfolg"); @@ -418,9 +399,113 @@ export async function rendereSicherung() { } }); - modal = erstelleModal("Sicherung löschen", text, [abbrechenBtn, loeschenBtn]); + modal = erstelleModal("Sicherung löschen", + erstelleElement("p", { text: `Soll die Sicherungsdatei '${fileName}' wirklich unwiderruflich gelöscht werden?` }), + [ + erstelleElement("button", { + type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen() + }), + loeschenBtn + ]); modal.oeffnen(); } ladeSicherungen(); } + +// ─── FTP-Upload (für alle Instanzen) ─── + +async function rendereFtp(bereich) { + bereich.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade FTP-Einstellungen…" })); + + let stand; + try { + stand = await api("/verwaltung/sicherung/ftp"); + } catch (err) { + bereich.replaceChildren(erstelleKarte("FTP-Upload", [ + erstelleElement("p", { klasse: "fehler-meldung", text: `Einstellungen nicht ladbar: ${err.message}` }) + ])); + return; + } + + const form = erstelleElement("form", { klasse: "formular" }); + + const an = checkbox("ftp-an", "Fertige Sicherungen automatisch hochladen", stand.enabled); + const host = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.host ?? "", placeholder: "ftp.example.org" }); + const port = erstelleElement("input", { type: "number", klasse: "eingabe", value: String(stand.port ?? 21), min: "1", max: "65535" }); + const benutzer = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.username ?? "", autocomplete: "off" }); + const kennwort = erstelleElement("input", { + type: "password", + klasse: "eingabe", + autocomplete: "new-password", + placeholder: stand.kennwortGesetzt ? "(gespeichert — leer lassen für unverändert)" : "Kennwort" + }); + const ordner = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.remoteDirectory ?? "/clawd-backups" }); + const behalten = erstelleElement("input", { type: "number", klasse: "eingabe", value: String(stand.keepCount ?? 14), min: "1", max: "365" }); + const tls = checkbox("ftp-tls", "Nur mit TLS verbinden", stand.requireTls); + + form.append( + erstelleElement("p", { + klasse: "form-hilfe", + text: "Die Sicherungen sind verschlüsselt — wer den Server liest, liest nichts Brauchbares. " + + "Das Kennwort des Servers geht ohne TLS aber im Klartext über die Leitung." + }), + an.gruppe, + feld("Server", host), + feld("Port", port), + feld("Benutzer", benutzer), + feld("Kennwort", kennwort), + feld("Zielordner", ordner, "Darunter bekommt jede Instanz einen eigenen Ordner."), + feld("Sicherungen je Instanz behalten", behalten, "Ältere werden auf dem Server gelöscht."), + tls.gruppe + ); + + const speichernBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: "Speichern" }); + const testBtn = erstelleElement("button", { + type: "button", + klasse: "schaltflaeche", + text: "Verbindung testen", + onclick: async () => { + testBtn.disabled = true; + try { + const res = await api("/verwaltung/sicherung/ftp/test", { method: "POST" }); + zeigeMeldung(res.meldung, res.ok ? "erfolg" : "warnung", 8000); + } catch (err) { + zeigeMeldung(`Test fehlgeschlagen: ${err.message}`, "gefahr"); + } finally { + testBtn.disabled = false; + } + } + }); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + speichernBtn.disabled = true; + try { + const res = await api("/verwaltung/sicherung/ftp", { + method: "PUT", + body: { + enabled: an.box.checked, + host: host.value.trim(), + port: Number(port.value) || 21, + username: benutzer.value.trim(), + password: kennwort.value || null, + remoteDirectory: ordner.value.trim(), + requireTls: tls.box.checked, + keepCount: Number(behalten.value) || 14 + } + }); + zeigeMeldung(res.meldung || "Gespeichert.", "erfolg"); + kennwort.value = ""; + rendereFtp(bereich); + } catch (err) { + zeigeMeldung(`Speichern fehlgeschlagen: ${err.message}`, "gefahr"); + } finally { + speichernBtn.disabled = false; + } + }); + + form.append(erstelleElement("div", { klasse: "gruppe-horizontal" }, testBtn, speichernBtn)); + + bereich.replaceChildren(erstelleKarte("FTP-Upload der Sicherungen", [form])); +} diff --git a/src/ClawdDotNet.Manager/BackupUpload.cs b/src/ClawdDotNet.Manager/BackupUpload.cs new file mode 100644 index 0000000..6d58033 --- /dev/null +++ b/src/ClawdDotNet.Manager/BackupUpload.cs @@ -0,0 +1,196 @@ +using System.Text.RegularExpressions; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Storage; +using FluentFTP; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Manager; + +/// +/// Wie Sicherungsdateien heißen — und woran man erkennt, welche zu welcher Instanz gehört. +/// +/// Ein Präfix ist keine Grenze. Das Aufräumen fragte bisher nur, ob ein Name +/// mit backup_<Instanz>_ beginnt. Bei zwei Instanzen, deren Namen ineinander +/// liegen (Test und Test_2), zählte die eine die Archive der anderen mit und +/// warf die überzähligen weg. Hier gehört eine Datei nur dann zu einer Instanz, wenn nach +/// dem Namen unmittelbar ein Zeitstempel folgt. +/// +public static class BackupNaming +{ + public static string SafeName(string instanceName) + => PortableFileName.Sanitize(instanceName, "Instanz"); + + /// Sekundengenau, damit zwei Sicherungen von Hand in einer Minute nicht kollidieren. + public static string FileNameFor(string instanceName, DateTime zeit) + => $"backup_{SafeName(instanceName)}_{zeit:yyyy-MM-dd_HHmmss}{LicenseBackupCipher.Extension}"; + + public static bool IsBackupFile(string fileName) + => fileName.EndsWith(LicenseBackupCipher.Extension, StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + + public static bool BelongsTo(string fileName, string instanceName) + => TimestampOf(fileName, instanceName) is not null; + + /// + /// Der Zeitpunkt aus dem Namen, oder null, wenn die Datei nicht zu dieser + /// Instanz gehört. Kennt auch die älteren Schreibweisen: die der täglichen Sicherung + /// in der Instanz (yyyy-MM-dd_HHmm) und die der ersten Verwaltungsansicht + /// (yyyyMMdd_HHmmss). + /// + public static DateTime? TimestampOf(string fileName, string instanceName) + { + var muster = new Regex( + "^backup_" + Regex.Escape(SafeName(instanceName)) + "_" + + @"(?:(?\d{4}-\d{2}-\d{2})_(?\d{4}(?:\d{2})?)|(?\d{8})_(?\d{6}))" + + @"\.(?:clawdbak|zip)$", + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + : RegexOptions.CultureInvariant); + + var treffer = muster.Match(fileName); + if (!treffer.Success) return null; + + var (datum, zeit, format) = treffer.Groups["d"].Success + ? (treffer.Groups["d"].Value, treffer.Groups["t"].Value, + treffer.Groups["t"].Value.Length == 4 ? "yyyy-MM-ddHHmm" : "yyyy-MM-ddHHmmss") + : (treffer.Groups["d2"].Value, treffer.Groups["t2"].Value, "yyyyMMddHHmmss"); + + return DateTime.TryParseExact(datum + zeit, format, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var wert) + ? wert + : null; + } +} + +/// Wohin fertige Sicherungen zusätzlich gehen. +public interface IBackupUploader +{ + bool IsConfigured { get; } + + /// Lädt hoch und räumt danach auf dem Ziel auf. + Task UploadAsync(string localPath, string instanceName, int keepCount, CancellationToken ct); + + /// Probiert die Verbindung. null heißt: geht. + Task TestAsync(CancellationToken ct); +} + +/// +/// Der Upload per FTP. +/// +/// Hochgeladen wird unter einem vorläufigen Namen und erst danach umbenannt. +/// Bricht die Verbindung mitten im Upload ab, liegt auf dem Server eine halbe Datei — und +/// unter ihrem richtigen Namen würde sie beim nächsten Aufräumen als gültige Sicherung +/// mitgezählt, während eine vollständige dafür gelöscht wird. +/// +/// TLS, wo es geht. Die Sicherung ist verschlüsselt, das Kennwort des Servers +/// nicht. Zertifikate werden nicht geprüft: Die meisten eigenen FTP-Server haben +/// selbstsignierte, und eine Prüfung hieße dort, gar nicht hochzuladen. Verschlüsselt ist +/// die Leitung trotzdem — gegen Mitlesen, nicht gegen einen, der sich dazwischenstellt. +/// +public sealed class FtpBackupUploader(Func settings, ILogger logger) : IBackupUploader +{ + public bool IsConfigured => settings().IsConfigured; + + public async Task UploadAsync(string localPath, string instanceName, int keepCount, CancellationToken ct) + { + var s = settings(); + if (!s.IsConfigured) return; + + using var client = await ConnectAsync(s, ct).ConfigureAwait(false); + + var ordner = RemoteFolder(s, instanceName); + await client.CreateDirectory(ordner, true, ct).ConfigureAwait(false); + + var ziel = ordner + "/" + Path.GetFileName(localPath); + var teil = ziel + ".part"; + + var status = await client.UploadFile( + localPath, teil, FtpRemoteExists.Overwrite, false, FtpVerify.None, null, ct) + .ConfigureAwait(false); + + if (status != FtpStatus.Success) + throw new IOException($"Der FTP-Server hat die Sicherung nicht angenommen ({status})."); + + if (await client.FileExists(ziel, ct).ConfigureAwait(false)) + await client.DeleteFile(ziel, ct).ConfigureAwait(false); + + await client.Rename(teil, ziel, ct).ConfigureAwait(false); + + logger.LogInformation("Sicherung hochgeladen: {Ziel} auf {Host}", ziel, s.Host); + + await RotateAsync(client, ordner, instanceName, Math.Max(keepCount, 1), ct).ConfigureAwait(false); + await client.Disconnect(ct).ConfigureAwait(false); + } + + public async Task TestAsync(CancellationToken ct) + { + var s = settings(); + if (string.IsNullOrWhiteSpace(s.Host)) return "Es ist kein Server eingetragen."; + + try + { + using var client = await ConnectAsync(s, ct).ConfigureAwait(false); + await client.GetListing("/", ct).ConfigureAwait(false); + var tls = client.IsEncrypted; + await client.Disconnect(ct).ConfigureAwait(false); + + return tls ? null : "Verbindung steht, aber ohne TLS — das Kennwort geht im Klartext über die Leitung."; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return ex.Message; + } + } + + private async Task ConnectAsync(BackupFtpSettings s, CancellationToken ct) + { + var client = new AsyncFtpClient(s.Host, s.Username, s.Password, s.Port); + client.Config.EncryptionMode = s.RequireTls ? FtpEncryptionMode.Explicit : FtpEncryptionMode.Auto; + client.Config.ValidateAnyCertificate = true; + client.Config.ConnectTimeout = 15_000; + + await client.Connect(ct).ConfigureAwait(false); + + if (!client.IsEncrypted) + { + logger.LogWarning( + "Die FTP-Verbindung zu {Host} ist unverschlüsselt — das Kennwort geht im Klartext " + + "über die Leitung. Die Sicherung selbst ist verschlüsselt. Mit requireTls lässt " + + "sich TLS erzwingen.", s.Host); + } + + return client; + } + + private async Task RotateAsync( + AsyncFtpClient client, string ordner, string instanceName, int keep, CancellationToken ct) + { + try + { + var alte = (await client.GetListing(ordner, ct).ConfigureAwait(false)) + .Where(i => i.Type == FtpObjectType.File && BackupNaming.BelongsTo(i.Name, instanceName)) + .OrderByDescending(i => BackupNaming.TimestampOf(i.Name, instanceName)) + .Skip(keep) + .ToList(); + + foreach (var datei in alte) + { + await client.DeleteFile(datei.FullName, ct).ConfigureAwait(false); + logger.LogInformation("Alte Sicherung auf dem Server entfernt: {Name}", datei.Name); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Die neue Sicherung liegt schon oben; ein gescheitertes Aufraeumen ist ein + // Hinweis, kein Fehlschlag des Uploads. + logger.LogWarning(ex, "Aufräumen auf dem FTP-Server fehlgeschlagen."); + } + } + + private static string RemoteFolder(BackupFtpSettings s, string instanceName) + { + var basis = string.IsNullOrWhiteSpace(s.RemoteDirectory) ? "" : s.RemoteDirectory.TrimEnd('/'); + return basis + "/" + BackupNaming.SafeName(instanceName); + } +} diff --git a/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj b/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj index d46f86a..a081186 100644 --- a/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj +++ b/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj @@ -27,4 +27,10 @@ + + + + + diff --git a/src/ClawdDotNet.Manager/ManagerBackups.cs b/src/ClawdDotNet.Manager/ManagerBackups.cs new file mode 100644 index 0000000..97b5fdf --- /dev/null +++ b/src/ClawdDotNet.Manager/ManagerBackups.cs @@ -0,0 +1,296 @@ +using System.Text.Json; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Core.Storage; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Manager; + +/// Was bei einer Sicherung herausgekommen ist. +/// Warum der Upload nicht geklappt hat — oder null. +public sealed record BackupOutcome( + string Path, string FileName, long SizeBytes, bool Uploaded, string? UploadProblem); + +/// +/// Die Sicherungen aller Instanzen, im Manager. +/// +/// Warum hier und nicht in der Instanz. Verschlüsselt wird mit dem +/// Lizenzschlüssel der Installation, und den hat seit E-M3 nur der Manager. Eine geführte +/// Instanz sichert sich deshalb nicht mehr selbst (siehe AppHost); ihre +/// Einstellungen — Schalter, Uhrzeit, Aufbewahrung, Zielordner — liest der Manager aus +/// ihrer InstanceSettings.json. +/// +/// Und deshalb dürfen jetzt auch die täglichen Sicherungen Zugangsdaten +/// enthalten. Bisher ließen sie sie weg, weil eine Passphrase dafür hätte gespeichert +/// werden müssen — neben den Sicherungen abgelegt wäre sie wirkungslos gewesen. Der +/// Lizenzschlüssel liegt nicht neben den Archiven. +/// +public sealed class ManagerBackups : IAsyncDisposable +{ + private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; + + private readonly ManagerSettingsStore _settings; + private readonly InstanceRegistry _registry; + private readonly IBackupUploader? _uploader; + private readonly ILogger _logger; + private readonly TimeProvider _clock; + private readonly TimeSpan _tick; + private readonly BackupService _service = new(); + private readonly CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _eineZurzeit = new(1, 1); + private readonly Dictionary _letzterLauf = new(PathBoundary.Comparer); + private Task? _loop; + + public ManagerBackups( + ManagerSettingsStore settings, + InstanceRegistry registry, + IBackupUploader? uploader, + ILogger logger, + TimeProvider? clock = null, + TimeSpan? tick = null) + { + _settings = settings; + _registry = registry; + _uploader = uploader; + _logger = logger; + _clock = clock ?? TimeProvider.System; + _tick = tick ?? TimeSpan.FromMinutes(1); + } + + /// Der eigene Lizenzschlüssel, oder null, wenn keiner eingetragen ist. + public string? LicenseKey + => string.IsNullOrWhiteSpace(_settings.Settings.LicenseKey) ? null : _settings.Settings.LicenseKey; + + public IBackupUploader? Uploader => _uploader; + + /// Die FTP-Einstellungen, wie sie gerade gelten. + public BackupFtpSettings FtpSettings => _settings.Settings.BackupFtp; + + /// + /// Übernimmt neue FTP-Einstellungen und schreibt manager.json. + /// + /// Ein leeres Kennwort heißt „unverändert": Die Oberfläche bekommt das + /// gespeicherte nie zu sehen und kann es deshalb auch nicht zurückschicken. + /// + public void UpdateFtp(BackupFtpSettings neu, string? kennwort) + { + var alt = _settings.Settings.BackupFtp; + neu.Password = string.IsNullOrEmpty(kennwort) ? alt.Password : kennwort; + + _settings.Settings.BackupFtp = neu; + _settings.Save(); + } + + // ─── Einstellungen der Instanz ─── + + /// + /// Die Instanzdatei. Eine unlesbare wird gemeldet, nicht verschluckt — sonst landeten + /// die Sicherungen wortlos im Standardverzeichnis statt im eingestellten. + /// + public InstanceConfig ReadConfig(ManagedInstance instanz) + { + var datei = Path.Combine(instanz.Directory, "InstanceSettings.json"); + + try + { + if (File.Exists(datei) + && JsonSerializer.Deserialize(File.ReadAllText(datei), Json) is { } config) + { + return config; + } + } + catch (Exception ex) when (ex is JsonException or IOException) + { + _logger.LogError(ex, + "InstanceSettings.json von {Name} ist nicht lesbar — für die Sicherungen gelten " + + "die Vorgaben, nicht die dort eingetragenen Werte.", instanz.Name); + } + + return new InstanceConfig { WorkingDirectory = instanz.Directory, InstanceName = instanz.Name }; + } + + public string BackupDirectory(ManagedInstance instanz) + => InstanceLayout.ResolveBackupDirectory(ReadConfig(instanz), instanz.Directory); + + // ─── Sichern ─── + + /// Legt eine verschlüsselte Sicherung an, räumt auf und lädt hoch. + public async Task CreateAsync( + ManagedInstance instanz, bool includeLogs, bool includeChatHistory, CancellationToken ct) + { + var lizenz = LicenseKey ?? throw new BackupException( + "Im Manager ist kein Lizenzschlüssel hinterlegt — ohne ihn lässt sich keine " + + "verschlüsselte Sicherung anlegen."); + + // Eine Sicherung zurzeit: VACUUM INTO und das Verschluesseln lasten Platte und + // Prozessor aus, und der Takt soll nicht neben einer von Hand gestarteten laufen. + await _eineZurzeit.WaitAsync(ct).ConfigureAwait(false); + + try + { + var config = ReadConfig(instanz); + var ordner = InstanceLayout.ResolveBackupDirectory(config, instanz.Directory); + Directory.CreateDirectory(ordner); + + var name = BackupNaming.FileNameFor(instanz.Name, _clock.GetLocalNow().DateTime); + var pfad = Path.Combine(ordner, name); + + var ergebnis = await _service.CreateAsync(instanz.Directory, pfad, new BackupOptions + { + Secrets = SecretMode.License, + LicenseKey = lizenz, + IncludeLogs = includeLogs, + IncludeChatHistory = includeChatHistory + }, ct).ConfigureAwait(false); + + _logger.LogInformation("Sicherung von {Name} erstellt: {Pfad} ({Groesse} Bytes)", + instanz.Name, pfad, ergebnis.SizeBytes); + + Rotate(ordner, instanz.Name, config.BackupKeepCount); + + var (hoch, problem) = await UploadAsync(pfad, instanz.Name, config.BackupKeepCount, ct) + .ConfigureAwait(false); + + return new BackupOutcome(pfad, name, ergebnis.SizeBytes, hoch, problem); + } + finally + { + _eineZurzeit.Release(); + } + } + + private async Task<(bool, string?)> UploadAsync( + string pfad, string instanz, int keep, CancellationToken ct) + { + if (_uploader is not { IsConfigured: true }) return (false, null); + + try + { + await _uploader.UploadAsync(pfad, instanz, keep, ct).ConfigureAwait(false); + return (true, null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Die Sicherung liegt lokal - ein gescheiterter Upload ist ein Befund, aber + // kein Grund, sie fuer misslungen zu erklaeren. + _logger.LogWarning(ex, "Upload der Sicherung von {Name} fehlgeschlagen.", instanz); + return (false, ex.Message); + } + } + + /// Behält die neuesten Sicherungen dieser Instanz. + public void Rotate(string ordner, string instanceName, int keep) + { + if (keep <= 0 || !Directory.Exists(ordner)) return; + + try + { + var alte = new DirectoryInfo(ordner).GetFiles() + .Select(f => (Datei: f, Zeit: BackupNaming.TimestampOf(f.Name, instanceName))) + .Where(x => x.Zeit is not null) + .OrderByDescending(x => x.Zeit) + .Skip(keep) + .ToList(); + + foreach (var (datei, _) in alte) + { + datei.Delete(); + _logger.LogInformation("Alte Sicherung entfernt: {Name}", datei.Name); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Aufräumen der Sicherungen von {Name} fehlgeschlagen.", instanceName); + } + } + + // ─── Der tägliche Takt ─── + + public void Start() => _loop ??= LoopAsync(_cts.Token); + + private async Task LoopAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(_tick, _clock); + + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) + { + try { await TickAsync(ct).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Durchgang der automatischen Sicherung fehlgeschlagen."); + } + } + } + + /// + /// Ein Durchgang über alle Instanzen. Öffentlich, damit Tests ihn auslösen können. + /// + /// Einmal am Tag, auch über einen Neustart des Managers hinweg. Nur im + /// Speicher zu zählen hieße: Wer den Manager nach der Sicherungszeit neu startet, + /// bekommt eine zweite Sicherung. Deshalb gilt auch eine Datei von heute, die nach der + /// eingestellten Uhrzeit entstanden ist, als erledigt. + /// + public async Task TickAsync(CancellationToken ct = default) + { + var jetzt = _clock.GetLocalNow().DateTime; + + foreach (var instanz in _registry.Instances.ToList()) + { + var config = ReadConfig(instanz); + + if (!config.AutoBackupEnabled) continue; + if (!TimeSpan.TryParse(config.AutoBackupTime, out var uhrzeit)) continue; + if (jetzt.TimeOfDay < uhrzeit) continue; + + if (_letzterLauf.TryGetValue(instanz.Directory, out var zuletzt) && zuletzt.Date == jetzt.Date) + continue; + + var faellig = jetzt.Date + uhrzeit; + var ordner = InstanceLayout.ResolveBackupDirectory(config, instanz.Directory); + + // Vor dem Lauf eintragen: Scheitert er, wird nicht jede Minute neu versucht, + // sondern morgen wieder. Ein Wiederholungssturm ueber Nacht hilft niemandem. + _letzterLauf[instanz.Directory] = jetzt; + + if (SchonErledigt(ordner, instanz.Name, faellig)) continue; + + try + { + var ergebnis = await CreateAsync(instanz, includeLogs: false, includeChatHistory: true, ct) + .ConfigureAwait(false); + + if (ergebnis.UploadProblem is { } problem) + _logger.LogWarning("Automatische Sicherung von {Name} liegt nur lokal: {Problem}", + instanz.Name, problem); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Automatische Sicherung von {Name} fehlgeschlagen.", instanz.Name); + } + } + } + + private static bool SchonErledigt(string ordner, string instanceName, DateTime faellig) + { + if (!Directory.Exists(ordner)) return false; + + return Directory.EnumerateFiles(ordner) + .Select(f => BackupNaming.TimestampOf(Path.GetFileName(f), instanceName)) + .Any(t => t is { } zeit && zeit >= faellig && zeit.Date == faellig.Date); + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync().ConfigureAwait(false); + + if (_loop is not null) + { + try { await _loop.ConfigureAwait(false); } + catch (OperationCanceledException) { /* erwartet */ } + } + + _cts.Dispose(); + _eineZurzeit.Dispose(); + } +} diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs index ae5426e..ecf7759 100644 --- a/src/ClawdDotNet.Manager/ManagerSettings.cs +++ b/src/ClawdDotNet.Manager/ManagerSettings.cs @@ -75,6 +75,15 @@ public sealed class ManagerSettings [JsonPropertyName("licenseKey")] public string LicenseKey { get; set; } = ""; + // ─── Sicherungen ─── + + /// + /// Wohin fertige Sicherungen zusätzlich hochgeladen werden. Aus, solange nichts + /// eingetragen ist. + /// + [JsonPropertyName("backupFtp")] + public BackupFtpSettings BackupFtp { get; set; } = new(); + // ─── Deploymentcenter ─── [JsonPropertyName("deploymentcenterUrl")] @@ -239,6 +248,9 @@ public sealed class ManagerSettingsStore Settings.DeploymentcenterMasterToken = TryUnprotect(Settings.DeploymentcenterMasterToken); Settings.WebUiToken = TryUnprotect(Settings.WebUiToken); + Settings.BackupFtp ??= new BackupFtpSettings(); + Settings.BackupFtp.Password = TryUnprotect(Settings.BackupFtp.Password); + return Settings; } @@ -247,12 +259,14 @@ public sealed class ManagerSettingsStore var lizenz = Settings.LicenseKey; var master = Settings.DeploymentcenterMasterToken; var web = Settings.WebUiToken; + var ftp = Settings.BackupFtp.Password; try { Settings.LicenseKey = SecretProtector.Protect(lizenz) ?? ""; Settings.DeploymentcenterMasterToken = SecretProtector.Protect(master) ?? ""; Settings.WebUiToken = SecretProtector.Protect(web) ?? ""; + Settings.BackupFtp.Password = SecretProtector.Protect(ftp) ?? ""; AppPaths.EnsureDirectory(Path.GetDirectoryName(SettingsPath)!); AtomicFile.WriteAllText(SettingsPath, JsonSerializer.Serialize(Settings, JsonOpts)); @@ -263,6 +277,7 @@ public sealed class ManagerSettingsStore Settings.LicenseKey = lizenz; Settings.DeploymentcenterMasterToken = master; Settings.WebUiToken = web; + Settings.BackupFtp.Password = ftp; } } @@ -276,3 +291,53 @@ public sealed class ManagerSettingsStore catch (SecretProtectionException) { return ""; } } } + +/// +/// Der FTP-Upload fertiger Sicherungen. +/// +/// Warum das vertretbar ist. Eine Sicherung auf einem fremden Server ist nur +/// deshalb unbedenklich, weil sie als Ganzes verschlüsselt ist +/// (LicenseBackupCipher): Wer den Server liest, liest Rauschen. Das Kennwort des +/// Servers dagegen ginge bei reinem FTP im Klartext über die Leitung — deshalb wird TLS +/// genommen, wo der Server es anbietet, und mit erzwungen. +/// +public sealed class BackupFtpSettings +{ + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("host")] + public string Host { get; set; } = ""; + + [JsonPropertyName("port")] + public int Port { get; set; } = 21; + + [JsonPropertyName("username")] + public string Username { get; set; } = ""; + + /// Verschlüsselt gespeichert. + [JsonPropertyName("password")] + public string Password { get; set; } = ""; + + /// Darunter bekommt jede Instanz einen eigenen Ordner. + [JsonPropertyName("remoteDirectory")] + public string RemoteDirectory { get; set; } = "/clawd-backups"; + + /// + /// Ohne TLS gar nicht verbinden. Aus heißt: TLS, wenn der Server es anbietet, sonst + /// ohne — dann steht eine Warnung im Protokoll. + /// + [JsonPropertyName("requireTls")] + public bool RequireTls { get; set; } + + /// + /// Wie viele Sicherungen je Instanz auf dem Server bleiben. Ohne Aufräumen füllt ein + /// automatischer Upload den Server, bis er voll ist — und dann schlägt ausgerechnet + /// die jüngste Sicherung fehl. + /// + [JsonPropertyName("keepCount")] + public int KeepCount { get; set; } = 14; + + [JsonIgnore] + public bool IsConfigured => Enabled && !string.IsNullOrWhiteSpace(Host); +} diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs index 24947d9..212b4a2 100644 --- a/src/ClawdDotNet.ManagerApp/Program.cs +++ b/src/ClawdDotNet.ManagerApp/Program.cs @@ -83,7 +83,26 @@ internal static class Program if (!await manager.StartAsync()) logger.LogWarning("Der Manager läuft ohne gültige Lizenz — er startet keine Instanz."); - await using var web = ManagerWebHost.TryStart(manager, loggerFactory); + // Die Sicherungen aller Instanzen - verschluesselt mit dem Lizenzschluessel, auf + // Wunsch zusaetzlich per FTP hinaus (#530). Nach dem Manager, weil sie dessen + // Register braucht, und vor der Weboberflaeche, weil die sie bedient. + var sicherungsLog = loggerFactory.CreateLogger("ClawdDotNet.Manager.Sicherung"); + + await using var backups = new ManagerBackups( + settings, manager.Instances, + new FtpBackupUploader(() => settings.Settings.BackupFtp, sicherungsLog), + sicherungsLog); + + if (backups.LicenseKey is null) + { + logger.LogWarning( + "Im Manager ist kein Lizenzschlüssel hinterlegt: Sicherungen lassen sich nicht " + + "verschlüsseln und werden deshalb nicht angelegt — auch die täglichen nicht."); + } + + backups.Start(); + + await using var web = ManagerWebHost.TryStart(manager, backups, loggerFactory); if (web is null) { diff --git a/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs b/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs new file mode 100644 index 0000000..e05d1b4 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs @@ -0,0 +1,254 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Security; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tests.Infrastructure; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Backup; + +/// +/// Die Sicherung, verschlüsselt mit dem Lizenzschlüssel. +/// +/// Worum es geht. Eine Sicherung soll einen Maschinenwechsel überstehen, +/// ohne dass eine Passphrase gespeichert oder eingetippt werden muss. Der Lizenzschlüssel +/// ist ohnehin da. Und ein Archiv auf einem FTP-Server oder einem USB-Stick soll niemandem +/// nützen, der es nur findet. +/// +/// Läuft in : Der Maschinenwechsel wird über den +/// statischen Schlüsselspeicher nachgestellt. +/// +[Collection(SecretKeyCollection.Name)] +public sealed class LicenseBackupTests : IDisposable +{ + private const string Lizenz = "CLAWD-ABCD-1234-EFGH-5678"; + private const string ApiKey = "sk-or-v1-streng-geheim-98765"; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "licbak-" + Guid.NewGuid().ToString("N")[..8]); + + private string Datei(string name) => Path.Combine(_root, name); + + public LicenseBackupTests() + { + Directory.CreateDirectory(_root); + SecretKeyStore.UseDirectory(Datei("maschine-a")); + } + + public void Dispose() + { + SecretKeyStore.UseDirectory(null); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_root, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private string Quelle(byte[] inhalt) + { + var pfad = Datei("quelle.bin"); + File.WriteAllBytes(pfad, inhalt); + return pfad; + } + + // ─── Der Behälter ─── + + /// + /// Mehr als ein Stück: Die Stückelung ist der Teil, an dem ein Fehler Daten + /// verschluckt, ohne dass es auffällt. + /// + [Fact] + public void Ein_Archiv_ueber_mehrere_Stuecke_kommt_unveraendert_zurueck() + { + var inhalt = RandomNumberGenerator.GetBytes(2_600_000); + + LicenseBackupCipher.Encrypt(Quelle(inhalt), "{}", Datei("a.clawdbak"), Lizenz); + LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz); + + File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe(inhalt); + } + + [Fact] + public void Auch_ein_leeres_Archiv_kommt_zurueck() + { + LicenseBackupCipher.Encrypt(Quelle([]), "{}", Datei("a.clawdbak"), Lizenz); + LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz); + + File.ReadAllBytes(Datei("zurueck.bin")).ShouldBeEmpty(); + } + + /// + /// Ein anderer Schlüssel wird am Kopf erkannt — bevor irgendetwas entschlüsselt und + /// vor allem bevor eine Instanz dafür angehalten wird. + /// + [Fact] + public void Ein_fremder_Schluessel_wird_erkannt_bevor_etwas_geschrieben_wird() + { + LicenseBackupCipher.Encrypt(Quelle([1, 2, 3]), "{}", Datei("a.clawdbak"), Lizenz); + + LicenseBackupCipher.MatchesKey(Datei("a.clawdbak"), "ANDERE-LIZENZ").ShouldBeFalse(); + LicenseBackupCipher.MatchesKey(Datei("a.clawdbak"), Lizenz).ShouldBeTrue(); + + Should.Throw(() => + LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), "ANDERE-LIZENZ")); + + File.Exists(Datei("zurueck.bin")).ShouldBeFalse(); + } + + /// + /// Im Wiederherstellungsdialog tippt ein Mensch einen fremden Schlüssel ab. Ein + /// Leerzeichen oder ein kleiner Buchstabe dürfen dort nicht über „verloren" entscheiden. + /// + [Fact] + public void Schreibweise_und_Leerzeichen_des_Schluessels_spielen_keine_Rolle() + { + LicenseBackupCipher.Encrypt(Quelle([4, 5, 6]), "{}", Datei("a.clawdbak"), " clawd-abcd-1234 -efgh-5678 "); + + LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz); + File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe([4, 5, 6]); + } + + [Fact] + public void Das_Manifest_ist_lesbar_ohne_das_Archiv_zu_entschluesseln() + { + LicenseBackupCipher.Encrypt(Quelle(RandomNumberGenerator.GetBytes(3_000_000)), + """{"instanceName":"Alpha"}""", Datei("a.clawdbak"), Lizenz); + + LicenseBackupCipher.ReadManifest(Datei("a.clawdbak"), Lizenz).ShouldContain("Alpha"); + } + + /// + /// Fehlt hinten ein ganzes Stück, muss das auffallen. Ohne Endmarke hielte man den Rest + /// für vollständig und bekäme ein ZIP, das beim Auspacken irgendwo abbricht. + /// + [Fact] + public void Ein_fehlendes_letztes_Stueck_faellt_auf() + { + var inhalt = RandomNumberGenerator.GetBytes(1024 * 1024 + 500); + const string manifest = "{}"; + + LicenseBackupCipher.Encrypt(Quelle(inhalt), manifest, Datei("a.clawdbak"), Lizenz); + + // Kopf 8+1+16, Manifest 12+4+16+len, erstes Stueck 12+4+16+1 MiB - danach kappen. + var bisErstesStueck = 25 + (32 + Encoding.UTF8.GetByteCount(manifest)) + (32 + 1024 * 1024); + var voll = File.ReadAllBytes(Datei("a.clawdbak")); + File.WriteAllBytes(Datei("a.clawdbak"), voll[..bisErstesStueck]); + + Should.Throw(() => + LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz)); + } + + [Fact] + public void Eine_Veraenderung_mitten_im_Archiv_faellt_auf() + { + LicenseBackupCipher.Encrypt(Quelle(RandomNumberGenerator.GetBytes(200_000)), "{}", + Datei("a.clawdbak"), Lizenz); + + var bytes = File.ReadAllBytes(Datei("a.clawdbak")); + bytes[bytes.Length / 2] ^= 0x01; + File.WriteAllBytes(Datei("a.clawdbak"), bytes); + + Should.Throw(() => + LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz)); + } + + // ─── Die Sicherung als Ganzes ─── + + private string Instanz() + { + var pfad = Datei("Instance-Alpha"); + Directory.CreateDirectory(pfad); + + AtomicFile.WriteAllText(Path.Combine(pfad, "InstanceSettings.json"), $$""" + { + "instanceId": "alpha-1", + "instanceName": "Alpha", + "openRouterApiKey": "{{SecretProtector.Protect(ApiKey)}}" + } + """); + + return pfad; + } + + private async Task SicherungAsync() + { + var ziel = Datei("backup_Alpha.clawdbak"); + + await new BackupService().CreateAsync(Instanz(), ziel, new BackupOptions + { + Secrets = SecretMode.License, + LicenseKey = Lizenz + }); + + return ziel; + } + + /// + /// Der Fall, um den es geht: Sichern auf Maschine A, zurückspielen auf Maschine B, und + /// der API-Schlüssel ist danach lesbar — ohne Passphrase, allein mit dem + /// Lizenzschlüssel. + /// + [Fact] + public async Task Eine_Sicherung_ueberlebt_den_Maschinenwechsel() + { + var sicherung = await SicherungAsync(); + + SecretKeyStore.UseDirectory(Datei("maschine-b")); + + var ziel = Datei("Wiederhergestellt"); + await new BackupService().RestoreAsync(sicherung, ziel, + new RestoreOptions { LicenseKey = Lizenz, Overwrite = true }); + + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(ziel, "InstanceSettings.json"))); + var wert = doc.RootElement.GetProperty("openRouterApiKey").GetString(); + + SecretProtector.Unprotect(wert).ShouldBe(ApiKey); + } + + /// + /// In der Datei steht nichts im Klartext — weder der Schlüssel noch, dass es überhaupt + /// einen gibt. + /// + [Fact] + public async Task Die_Sicherung_verraet_keinen_Klartext() + { + var sicherung = await SicherungAsync(); + + LicenseBackupCipher.IsEncrypted(sicherung).ShouldBeTrue(); + + var roh = Encoding.Latin1.GetString(File.ReadAllBytes(sicherung)); + roh.ShouldNotContain(ApiKey); + roh.ShouldNotContain("openRouterApiKey"); + roh.ShouldNotContain("InstanceSettings"); + } + + /// + /// Mit fremdem Schlüssel wird nichts geschrieben — nicht halb, nicht teilweise. + /// + [Fact] + public async Task Mit_fremdem_Schluessel_bleibt_das_Ziel_unberuehrt() + { + var sicherung = await SicherungAsync(); + var ziel = Datei("Ziel"); + Directory.CreateDirectory(ziel); + + await Should.ThrowAsync(() => + new BackupService().RestoreAsync(sicherung, ziel, + new RestoreOptions { LicenseKey = "ANDERE-LIZENZ", Overwrite = true })); + + Directory.EnumerateFileSystemEntries(ziel).ShouldBeEmpty(); + } + + [Fact] + public async Task Die_Uebersicht_braucht_den_passenden_Schluessel() + { + var sicherung = await SicherungAsync(); + + (await new BackupService().InspectAsync(sicherung, licenseKey: Lizenz)) + .InstanceName.ShouldBe("Alpha"); + + await Should.ThrowAsync(() => + new BackupService().InspectAsync(sicherung, licenseKey: "ANDERE-LIZENZ")); + } +} diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerBackupsTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerBackupsTests.cs new file mode 100644 index 0000000..d9c3678 --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/ManagerBackupsTests.cs @@ -0,0 +1,283 @@ +using System.Text.Json; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// Die Sicherungen im Manager (#530): verschlüsselt mit dem Lizenzschlüssel, einmal am +/// Tag, aufgeräumt, auf Wunsch hochgeladen. +/// +public sealed class ManagerBackupsTests : IAsyncDisposable +{ + private const string Lizenz = "CLAWD-TEST-0000-1111-2222"; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "mbak-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly InstanceDirectoryManager _verzeichnisse; + private readonly ManagerSettingsStore _settings; + private readonly FakeUploader _upload = new(); + private readonly FesteUhr _uhr = new(new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero)); + + public ManagerBackupsTests() + { + Directory.CreateDirectory(_root); + _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + _settings = new ManagerSettingsStore(_root); + _settings.Settings.LicenseKey = Lizenz; + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + private string Instanz(string name, bool automatisch = true, string uhrzeit = "03:00", int behalten = 14) + { + var pfad = _verzeichnisse.CreateInstance(name); + var datei = Path.Combine(pfad, "InstanceSettings.json"); + + var config = JsonSerializer.Deserialize(File.ReadAllText(datei))!; + config.AutoBackupEnabled = automatisch; + config.AutoBackupTime = uhrzeit; + config.BackupKeepCount = behalten; + File.WriteAllText(datei, JsonSerializer.Serialize(config)); + + return pfad; + } + + private async Task DienstAsync(IBackupUploader? upload = null) + { + var register = new InstanceRegistry( + _verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance, + SupervisionOptions.Default); + + await register.RefreshAsync(); + + return new ManagerBackups( + _settings, register, upload ?? _upload, NullLogger.Instance, _uhr); + } + + private static string[] Sicherungen(string pfad) + { + var ordner = Path.Combine(pfad, "Backups"); + return Directory.Exists(ordner) ? Directory.GetFiles(ordner) : []; + } + + // ─── Namen ─── + + /// + /// Ein Präfix ist keine Grenze: Die Archive von Test_2 gehören nicht zu + /// Test — sonst wirft das Aufräumen der einen die Sicherungen der anderen weg. + /// + [Fact] + public void Ineinanderliegende_Instanznamen_werden_nicht_verwechselt() + { + var eigene = BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 11, 3, 0, 0)); + var fremde = BackupNaming.FileNameFor("Test_2", new DateTime(2026, 9, 11, 3, 0, 0)); + + BackupNaming.BelongsTo(eigene, "Test").ShouldBeTrue(); + BackupNaming.BelongsTo(fremde, "Test").ShouldBeFalse(); + BackupNaming.BelongsTo(fremde, "Test_2").ShouldBeTrue(); + } + + [Theory] + [InlineData("backup_Alpha_2026-09-11_0300.zip")] // taegliche Sicherung der Instanz + [InlineData("backup_Alpha_20260911_030000.zip")] // erste Verwaltungsansicht + [InlineData("backup_Alpha_2026-09-11_030000.clawdbak")] // Manager + public void Aeltere_Schreibweisen_werden_erkannt(string name) + { + BackupNaming.TimestampOf(name, "Alpha").ShouldBe(new DateTime(2026, 9, 11, 3, 0, 0)); + } + + [Theory] + [InlineData("notizen.txt")] + [InlineData("backup_Alpha_kaputt.zip")] + [InlineData("backup_Alpha_2026-09-11_0300.zip.part")] + public void Fremde_Dateien_gehoeren_niemandem(string name) + { + BackupNaming.BelongsTo(name, "Alpha").ShouldBeFalse(); + } + + // ─── Sichern ─── + + [Fact] + public async Task Ohne_Lizenzschluessel_wird_keine_Sicherung_angelegt() + { + var pfad = Instanz("Alpha"); + _settings.Settings.LicenseKey = ""; + + await using var dienst = await DienstAsync(); + var instanz = (await RegisterAsync()).Single(); + + await Should.ThrowAsync(() => + dienst.CreateAsync(instanz, false, true, CancellationToken.None)); + + Sicherungen(pfad).ShouldBeEmpty("Unverschlüsselt wird nichts abgelegt — auch nicht ersatzweise."); + } + + [Fact] + public async Task Eine_Sicherung_ist_verschluesselt_und_wird_hochgeladen() + { + var pfad = Instanz("Alpha"); + + await using var dienst = await DienstAsync(); + var instanz = (await RegisterAsync()).Single(); + + var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None); + + LicenseBackupCipher.IsEncrypted(ergebnis.Path).ShouldBeTrue(); + LicenseBackupCipher.MatchesKey(ergebnis.Path, Lizenz).ShouldBeTrue(); + + ergebnis.Uploaded.ShouldBeTrue(); + _upload.Hochgeladen.ShouldBe([ergebnis.Path]); + } + + /// + /// Ein gescheiterter Upload ist ein Befund, aber kein Grund, die Sicherung für + /// misslungen zu erklären — sie liegt ja lokal. + /// + [Fact] + public async Task Ein_gescheiterter_Upload_laesst_die_Sicherung_stehen() + { + Instanz("Alpha"); + _upload.Fehler = new IOException("Server nicht erreichbar"); + + await using var dienst = await DienstAsync(); + var instanz = (await RegisterAsync()).Single(); + + var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None); + + File.Exists(ergebnis.Path).ShouldBeTrue(); + ergebnis.Uploaded.ShouldBeFalse(); + ergebnis.UploadProblem.ShouldBe("Server nicht erreichbar"); + } + + [Fact] + public async Task Aufgeraeumt_wird_nur_die_eigene_Instanz() + { + var ordner = Path.Combine(_root, "ablage"); + Directory.CreateDirectory(ordner); + + for (var tag = 1; tag <= 5; tag++) + { + var zeit = new DateTime(2026, 9, tag, 3, 0, 0); + File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test", zeit)), ""); + File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test_2", zeit)), ""); + } + + await using var dienst = await DienstAsync(); + dienst.Rotate(ordner, "Test", keep: 2); + + var namen = Directory.GetFiles(ordner).Select(Path.GetFileName).ToList(); + + namen.Count(n => BackupNaming.BelongsTo(n!, "Test")).ShouldBe(2); + namen.Count(n => BackupNaming.BelongsTo(n!, "Test_2")).ShouldBe(5, "Die andere Instanz bleibt unberührt."); + namen.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0))); + } + + // ─── Der tägliche Takt ─── + + [Fact] + public async Task Vor_der_Uhrzeit_passiert_nichts() + { + var pfad = Instanz("Alpha", uhrzeit: "03:00"); + + await using var dienst = await DienstAsync(); + await dienst.TickAsync(); // 02:00 + + Sicherungen(pfad).ShouldBeEmpty(); + } + + [Fact] + public async Task Einmal_am_Tag_und_nicht_oefter() + { + var pfad = Instanz("Alpha", uhrzeit: "03:00"); + + await using var dienst = await DienstAsync(); + + _uhr.Jetzt = _uhr.Jetzt.AddHours(1.5); // 03:30 + await dienst.TickAsync(); + + _uhr.Jetzt = _uhr.Jetzt.AddMinutes(30); // 04:00 + await dienst.TickAsync(); + + Sicherungen(pfad).Length.ShouldBe(1); + } + + /// + /// Auch über einen Neustart des Managers hinweg. Nur im Speicher zu zählen hieße: Wer + /// nach der Sicherungszeit neu startet, bekommt eine zweite Sicherung. + /// + [Fact] + public async Task Ein_Neustart_des_Managers_erzeugt_keine_zweite_Sicherung() + { + var pfad = Instanz("Alpha", uhrzeit: "03:00"); + _uhr.Jetzt = _uhr.Jetzt.AddHours(1.5); + + await using (var erster = await DienstAsync()) + await erster.TickAsync(); + + _uhr.Jetzt = _uhr.Jetzt.AddHours(1); + + await using (var zweiter = await DienstAsync()) + await zweiter.TickAsync(); + + Sicherungen(pfad).Length.ShouldBe(1); + } + + [Fact] + public async Task Ohne_Schalter_keine_automatische_Sicherung() + { + var pfad = Instanz("Alpha", automatisch: false); + _uhr.Jetzt = _uhr.Jetzt.AddHours(5); + + await using var dienst = await DienstAsync(); + await dienst.TickAsync(); + + Sicherungen(pfad).ShouldBeEmpty(); + } + + // ─── Hilfen ─── + + private async Task> RegisterAsync() + { + var register = new InstanceRegistry( + _verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance, + SupervisionOptions.Default); + + await register.RefreshAsync(); + return register.Instances; + } + + private sealed class FakeUploader : IBackupUploader + { + public List Hochgeladen { get; } = []; + public Exception? Fehler { get; set; } + public bool IsConfigured => true; + + public Task UploadAsync(string localPath, string instanceName, int keepCount, CancellationToken ct) + { + if (Fehler is not null) throw Fehler; + Hochgeladen.Add(localPath); + return Task.CompletedTask; + } + + public Task TestAsync(CancellationToken ct) => Task.FromResult(null); + } + + /// Eine Uhr in UTC, damit „lokal" im Test überall dasselbe heißt. + private sealed class FesteUhr(DateTimeOffset start) : TimeProvider + { + public DateTimeOffset Jetzt { get; set; } = start; + public override DateTimeOffset GetUtcNow() => Jetzt; + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + } +}