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;
+ }
+}