Sicherungen mit dem Lizenzschluessel verschluesselt, optional per FTP (#530)
Eine Sicherung des Managers ist jetzt als Ganzes mit dem Lizenzschluessel verschluesselt. Beim Zurueckspielen wird der eigene Schluessel probiert; passt er nicht, fragt die Oberflaeche nach dem Lizenzschluessel der Quellinstallation. Der Lizenzschluessel ist der eine Wert, den jede Installation ohnehin hat, und er liegt im Manager, nicht neben den Archiven. Damit faellt der Grund weg, aus dem die taegliche Sicherung bisher keine Zugangsdaten enthielt: Eine Passphrase haette gespeichert werden muessen, und neben den Sicherungen waere sie wirkungslos gewesen. Jetzt reisen die Zugangsdaten mit, und eine Sicherung uebersteht den Maschinenwechsel ohne Passphrase. LicenseBackupCipher: eigener Behaelter .clawdbak. Kopf mit Schluesselprobe, das Manifest als eigener Abschnitt, das ZIP in 1-MiB-Stuecken mit AES-256-GCM und Endmarke. PBKDF2 aus Lizenzschluessel und festem Salt, der Schluessel normiert. Kein RAR (braucht das lizenzpflichtige Herstellerprogramm), kein ZIP mit Kennwort (kann .NET nicht schreiben, ZipCrypto ist gebrochen, und mit abgeleitetem Schluessel braechte 7-Zip-Vertraeglichkeit nichts). Zurueckspielen: Die Schluesselprobe laeuft VOR dem Anhalten der Instanz. Passt der Schluessel nicht, antwortet der Endpunkt mit 422 und dem Code fremde_lizenz, und ein Fenster verlangt den Schluessel der Quelle. ManagerBackups: Gefuehrte Instanzen sichern sich nicht mehr selbst, weil nur der Manager den Schluessel hat; er liest ihre Einstellungen aus InstanceSettings.json. Einmal am Tag auch ueber einen Neustart des Managers hinweg. Ohne Lizenzschluessel wird nichts gesichert, auch nicht ersatzweise unverschluesselt. FTP (FluentFTP, schon im Loesungsbaum): TLS wo angeboten, requireTls erzwingt es; Upload unter .part-Namen und danach umbenannt; Aufraeumen auf dem Server je Instanz; ein gescheiterter Upload laesst die lokale Sicherung stehen. Das Kennwort liegt verschluesselt in manager.json und geht nie an die Oberflaeche. Welche Datei zu welcher Instanz gehoert, entscheidet ein Zeitstempel nach dem Namen und nicht das Praefix - Test und Test_2 werden nicht mehr verwechselt. Grenze, bewusst und dokumentiert (Konzept 9.10): Wer Lizenzschluessel und Archiv hat, hat die Zugangsdaten. Der Schluessel ist dem Hersteller bekannt und gilt fuer alle Instanzen einer Installation. Nicht ueber die Oberflaeche abgenommen: Fenster fuer die fremde Lizenz und FTP gegen einen echten Server stehen als Block H in #537. 860 Tests gruen, 0 Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
+5
-9
@@ -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
|
||||
|
||||
@@ -412,7 +412,19 @@ public sealed class AppHost : IAsyncDisposable
|
||||
instancePath, instance,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Backup"));
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -16,7 +16,18 @@ public enum SecretMode
|
||||
/// Zugangsdaten werden mit einer Passphrase geschützt. Nur so überstehen sie
|
||||
/// einen Rechner- oder Benutzerwechsel.
|
||||
/// </summary>
|
||||
Passphrase
|
||||
Passphrase,
|
||||
|
||||
/// <summary>
|
||||
/// Die ganze Sicherung wird mit dem Lizenzschlüssel verschlüsselt
|
||||
/// (<see cref="LicenseBackupCipher"/>), die Zugangsdaten reisen mit.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
License
|
||||
}
|
||||
|
||||
public sealed record BackupOptions
|
||||
@@ -26,6 +37,9 @@ public sealed record BackupOptions
|
||||
/// <summary>Erforderlich bei <see cref="SecretMode.Passphrase"/>.</summary>
|
||||
public string? Passphrase { get; init; }
|
||||
|
||||
/// <summary>Erforderlich bei <see cref="SecretMode.License"/>.</summary>
|
||||
public string? LicenseKey { get; init; }
|
||||
|
||||
/// <summary>Protokolle sind groß und selten nötig.</summary>
|
||||
public bool IncludeLogs { get; init; }
|
||||
|
||||
@@ -37,6 +51,12 @@ public sealed record RestoreOptions
|
||||
{
|
||||
public string? Passphrase { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Für verschlüsselte Sicherungen: der Lizenzschlüssel der Installation, von der sie
|
||||
/// stammt. Passt er nicht, kommt <see cref="BackupKeyMismatchException"/>.
|
||||
/// </summary>
|
||||
public string? LicenseKey { get; init; }
|
||||
|
||||
/// <summary>Nur prüfen und berichten, nichts schreiben.</summary>
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
@@ -74,8 +94,12 @@ public sealed record BackupManifest
|
||||
[JsonPropertyName("files")]
|
||||
public List<BackupEntry> Files { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Enthält die Sicherung Zugangsdaten, die beim Zurückspielen auf den Schlüssel der
|
||||
/// Zielmaschine umgeschlüsselt werden müssen?
|
||||
/// </summary>
|
||||
[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)
|
||||
/// <summary>Nicht versiegelt: <see cref="BackupKeyMismatchException"/> ist ein Sonderfall davon.</summary>
|
||||
public class BackupException(string message, Exception? inner = null)
|
||||
: Exception(message, inner);
|
||||
|
||||
@@ -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,11 +114,35 @@ public sealed class BackupService
|
||||
if (File.Exists(targetZipPath))
|
||||
File.Delete(targetZipPath);
|
||||
|
||||
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);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(staging);
|
||||
@@ -148,8 +175,24 @@ public sealed class BackupService
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>Liest das Manifest, ohne etwas auszupacken.</summary>
|
||||
public async Task<BackupManifest> InspectAsync(string zipPath, CancellationToken ct = default)
|
||||
/// <param name="licenseKey">Nur für verschlüsselte Sicherungen nötig.</param>
|
||||
/// <exception cref="BackupKeyMismatchException">
|
||||
/// Verschlüsselt, und der Schlüssel passt nicht.
|
||||
/// </exception>
|
||||
public async Task<BackupManifest> 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<BackupManifest>(
|
||||
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<RestoreResult> 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<RestoreResult> 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); }
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ClawdDotNet.Core.Backup;
|
||||
|
||||
/// <summary>Die Sicherung wurde mit einem anderen Lizenzschlüssel verschlüsselt.</summary>
|
||||
public sealed class BackupKeyMismatchException(string message) : BackupException(message);
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt eine ganze Sicherung mit dem Lizenzschlüssel der Installation.
|
||||
///
|
||||
/// <para><b>Warum der Lizenzschlüssel.</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Was das aushält und was nicht.</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Aufbau.</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Kein ZIP mit Kennwort, kein RAR.</b> 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.</para>
|
||||
/// </summary>
|
||||
public static class LicenseBackupCipher
|
||||
{
|
||||
/// <summary>Die Endung verschlüsselter Sicherungen.</summary>
|
||||
public const string Extension = ".clawdbak";
|
||||
|
||||
private static readonly byte[] Magic = "CLAWDBAK"u8.ToArray();
|
||||
private const byte FormatVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Abgeleitete Schlüssel je Lizenzschlüssel. PBKDF2 kostet mit Absicht Zeit — die
|
||||
/// Liste von vierzehn Sicherungen soll sie nicht vierzehnmal zahlen.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, byte[]> Keys = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bringt einen Lizenzschlüssel in eine feste Form.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public static string NormalizeKey(string licenseKey)
|
||||
=> new string(licenseKey.Where(c => !char.IsWhiteSpace(c)).ToArray()).ToUpperInvariant();
|
||||
|
||||
/// <summary>Ist diese Datei eine verschlüsselte Sicherung?</summary>
|
||||
public static bool IsEncrypted(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var datei = File.OpenRead(path);
|
||||
Span<byte> kopf = stackalloc byte[8];
|
||||
return datei.Read(kopf) == 8 && kopf.SequenceEqual(Magic);
|
||||
}
|
||||
catch (IOException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Passt dieser Lizenzschlüssel zu der Sicherung? Liest nur den Kopf.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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)));
|
||||
}
|
||||
|
||||
/// <summary>Verschlüsselt ein fertiges ZIP samt Manifest in die Zieldatei.</summary>
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Liest das Manifest, ohne das Archiv zu entschlüsseln.</summary>
|
||||
/// <exception cref="BackupKeyMismatchException">Anderer Lizenzschlüssel.</exception>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Entschlüsselt das Archiv in eine ZIP-Datei.</summary>
|
||||
/// <exception cref="BackupKeyMismatchException">Anderer Lizenzschlüssel.</exception>
|
||||
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<byte> 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<byte> 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<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -34,23 +34,45 @@ public sealed record BackupItemDto(
|
||||
string InstanceName,
|
||||
string Secrets,
|
||||
int SecretCount,
|
||||
int FileCount);
|
||||
int FileCount,
|
||||
bool Verschluesselt,
|
||||
bool FremdeLizenz);
|
||||
|
||||
/// <summary>Übersicht aller Sicherungen einer Instanz mit Verzeichnisangabe.</summary>
|
||||
public sealed record BackupListDto(
|
||||
string Directory,
|
||||
IReadOnlyList<BackupItemDto> Backups);
|
||||
|
||||
/// <summary>Parameter zum Anlegen einer neuen Sicherung.</summary>
|
||||
/// <summary>
|
||||
/// Parameter zum Anlegen einer neuen Sicherung. Wie die Zugangsdaten geschützt werden,
|
||||
/// ist keine Frage mehr: Der Manager verschlüsselt immer mit dem Lizenzschlüssel.
|
||||
/// </summary>
|
||||
public sealed record CreateBackupRequestDto(
|
||||
string? Secrets,
|
||||
string? Passphrase,
|
||||
bool IncludeLogs,
|
||||
bool IncludeChatHistory);
|
||||
|
||||
/// <summary>Parameter zur Wiederherstellung einer Sicherung.</summary>
|
||||
/// <param name="Passphrase">Nur für ältere Sicherungen mit Passphrase.</param>
|
||||
/// <param name="Lizenzschluessel">
|
||||
/// Der Lizenzschlüssel der Installation, von der die Sicherung stammt — nur nötig, wenn
|
||||
/// es nicht die eigene ist.
|
||||
/// </param>
|
||||
public sealed record RestoreBackupRequestDto(
|
||||
string? Passphrase);
|
||||
string? Passphrase,
|
||||
string? Lizenzschluessel);
|
||||
|
||||
/// <summary>Antwort, wenn für eine Sicherung ein anderer Lizenzschlüssel gebraucht wird.</summary>
|
||||
public sealed record KeyNeededDto(bool Ok, string Meldung, string Code);
|
||||
|
||||
/// <summary>Die FTP-Einstellungen, wie die Oberfläche sie sieht — ohne Kennwort.</summary>
|
||||
public sealed record FtpSettingsDto(
|
||||
bool Enabled, string Host, int Port, string Username, bool KennwortGesetzt,
|
||||
string RemoteDirectory, bool RequireTls, int KeepCount);
|
||||
|
||||
/// <summary>Neue FTP-Einstellungen. Ein leeres Kennwort heißt: unverändert.</summary>
|
||||
public sealed record FtpSettingsUpdateDto(
|
||||
bool Enabled, string Host, int Port, string Username, string? Password,
|
||||
string RemoteDirectory, bool RequireTls, int KeepCount);
|
||||
|
||||
/// <summary>
|
||||
/// Die HTTP-Fläche des Managers.
|
||||
@@ -157,158 +179,164 @@ internal static class ManagerEndpoints
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
});
|
||||
|
||||
// ─── Datensicherung & Wiederherstellung (#530, Stufe 6) ───
|
||||
// ─── Datensicherung & Wiederherstellung (#530) ───
|
||||
//
|
||||
// Jede Sicherung, die der Manager anlegt, ist als Ganzes mit dem Lizenzschluessel
|
||||
// verschluesselt (LicenseBackupCipher). Beim Zurueckspielen wird zuerst der eigene
|
||||
// probiert; passt er nicht, antwortet der Endpunkt mit 422 und dem Code
|
||||
// "fremde_lizenz", und die Oberflaeche fragt nach dem Schluessel der Quelle.
|
||||
|
||||
verwaltung.MapGet("/instanzen/{id}/backups", async (
|
||||
string id, InstanceRegistry registry, ILoggerFactory protokoll,
|
||||
CancellationToken ct) =>
|
||||
string id, InstanceRegistry registry, ManagerBackups backups, CancellationToken ct) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
|
||||
if (!Directory.Exists(backupDir))
|
||||
return Results.Ok(new BackupListDto(backupDir, []));
|
||||
var ordner = backups.BackupDirectory(instanz);
|
||||
if (!Directory.Exists(ordner))
|
||||
return Results.Ok(new BackupListDto(ordner, []));
|
||||
|
||||
var service = new BackupService();
|
||||
var list = new List<BackupItemDto>();
|
||||
var liste = new List<BackupItemDto>();
|
||||
|
||||
var files = new DirectoryInfo(backupDir)
|
||||
.GetFiles("*.zip")
|
||||
var dateien = new DirectoryInfo(ordner).GetFiles()
|
||||
.Where(f => BackupNaming.IsBackupFile(f.Name))
|
||||
.OrderByDescending(f => f.LastWriteTimeUtc);
|
||||
|
||||
foreach (var f in files)
|
||||
foreach (var f in dateien)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var verschluesselt = LicenseBackupCipher.IsEncrypted(f.FullName);
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = await service.InspectAsync(f.FullName, ct);
|
||||
list.Add(new BackupItemDto(
|
||||
f.Name,
|
||||
f.Length,
|
||||
var manifest = await service.InspectAsync(f.FullName, ct, backups.LicenseKey);
|
||||
|
||||
liste.Add(new BackupItemDto(
|
||||
f.Name, f.Length,
|
||||
manifest.CreatedAt != default ? manifest.CreatedAt : f.CreationTimeUtc,
|
||||
manifest.InstanceName,
|
||||
manifest.Secrets,
|
||||
manifest.SecretCount,
|
||||
manifest.Files.Count));
|
||||
manifest.InstanceName, manifest.Secrets, manifest.SecretCount,
|
||||
manifest.Files.Count, verschluesselt, FremdeLizenz: false));
|
||||
}
|
||||
catch
|
||||
catch (BackupKeyMismatchException)
|
||||
{
|
||||
list.Add(new BackupItemDto(
|
||||
f.Name,
|
||||
f.Length,
|
||||
f.CreationTimeUtc,
|
||||
instanz.Name,
|
||||
"Unbekannt",
|
||||
0,
|
||||
0));
|
||||
// Stammt von einer anderen Installation. Kein Fehler, sondern eine
|
||||
// Auskunft - zurueckspielen laesst sie sich mit deren Schluessel.
|
||||
liste.Add(new BackupItemDto(
|
||||
f.Name, f.Length, f.CreationTimeUtc, instanz.Name,
|
||||
nameof(SecretMode.License), 0, 0, Verschluesselt: true, FremdeLizenz: true));
|
||||
}
|
||||
catch (Exception ex) when (ex is BackupException or IOException or InvalidDataException)
|
||||
{
|
||||
liste.Add(new BackupItemDto(
|
||||
f.Name, f.Length, f.CreationTimeUtc, instanz.Name,
|
||||
"Unlesbar", 0, 0, verschluesselt, FremdeLizenz: false));
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new BackupListDto(backupDir, list));
|
||||
return Results.Ok(new BackupListDto(ordner, liste));
|
||||
});
|
||||
|
||||
verwaltung.MapPost("/instanzen/{id}/backups", async (
|
||||
string id, CreateBackupRequestDto? req, InstanceRegistry registry,
|
||||
ILoggerFactory protokoll, CancellationToken ct) =>
|
||||
string id, CreateBackupRequestDto? req, InstanceRegistry registry, ManagerBackups backups) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
|
||||
Directory.CreateDirectory(backupDir);
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
var safeName = string.Join("_", instanz.Name.Split(Path.GetInvalidFileNameChars()));
|
||||
var zipName = $"backup_{safeName}_{timestamp}.zip";
|
||||
var zipPath = Path.Combine(backupDir, zipName);
|
||||
|
||||
var secretMode = string.Equals(req?.Secrets, "Passphrase", StringComparison.OrdinalIgnoreCase)
|
||||
? SecretMode.Passphrase
|
||||
: SecretMode.Exclude;
|
||||
|
||||
if (secretMode == SecretMode.Passphrase && string.IsNullOrWhiteSpace(req?.Passphrase))
|
||||
return Results.BadRequest(new OutcomeDto(false, "Für den Schutz der Zugangsdaten wird eine Passphrase benötigt."));
|
||||
|
||||
var options = new BackupOptions
|
||||
{
|
||||
Secrets = secretMode,
|
||||
Passphrase = req?.Passphrase,
|
||||
IncludeLogs = req?.IncludeLogs ?? false,
|
||||
IncludeChatHistory = req?.IncludeChatHistory ?? true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var service = new BackupService();
|
||||
var result = await service.CreateAsync(instanz.Directory, zipPath, options, ct);
|
||||
return Results.Ok(new OutcomeDto(true, $"Sicherung '{zipName}' erfolgreich erstellt ({result.SizeBytes / 1024} KB)."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
// 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
|
||||
{
|
||||
return Results.BadRequest(new OutcomeDto(false, $"Sicherung fehlgeschlagen: {ex.Message}"));
|
||||
{ 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 (BackupException ex)
|
||||
{
|
||||
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 stopped = await manager.StopInstanceAsync(instanz.Directory, ct);
|
||||
if (!stopped)
|
||||
var liefVorher = instanz.Condition is InstanceCondition.Running or InstanceCondition.Silent;
|
||||
if (liefVorher && !await manager.StopInstanceAsync(instanz.Directory, ct))
|
||||
{
|
||||
return Results.Json(
|
||||
new OutcomeDto(false, $"Instanz '{instanz.Name}' konnte vor der Wiederherstellung nicht angehalten werden."),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var service = new BackupService();
|
||||
var restoreResult = await service.RestoreAsync(
|
||||
zipPath,
|
||||
instanz.Directory,
|
||||
var ergebnis = await new BackupService().RestoreAsync(pfad, instanz.Directory,
|
||||
new RestoreOptions
|
||||
{
|
||||
Passphrase = req?.Passphrase,
|
||||
LicenseKey = schluessel,
|
||||
Overwrite = true
|
||||
},
|
||||
ct);
|
||||
}, ct);
|
||||
|
||||
if (wasRunning)
|
||||
{
|
||||
if (liefVorher)
|
||||
await manager.StartInstanceAsync(instanz.Directory, ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new OutcomeDto(true, $"Sicherung '{filename}' erfolgreich wiederhergestellt ({restoreResult.Written.Count} Dateien geschrieben)."));
|
||||
return Results.Ok(new OutcomeDto(true,
|
||||
$"Sicherung '{filename}' wiederhergestellt ({ergebnis.Written.Count} Dateien geschrieben)."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (wasRunning)
|
||||
if (liefVorher)
|
||||
{
|
||||
try { await manager.StartInstanceAsync(instanz.Directory, ct); } catch { }
|
||||
try { await manager.StartInstanceAsync(instanz.Directory, ct); }
|
||||
catch { /* Der eigentliche Fehler ist der, der gemeldet wird. */ }
|
||||
}
|
||||
|
||||
return Results.BadRequest(new OutcomeDto(false, $"Wiederherstellung fehlgeschlagen: {ex.Message}"));
|
||||
@@ -316,30 +344,72 @@ internal static class ManagerEndpoints
|
||||
});
|
||||
|
||||
verwaltung.MapDelete("/instanzen/{id}/backups/{filename}", (
|
||||
string id, string filename, InstanceRegistry registry, ILoggerFactory protokoll) =>
|
||||
string id, string filename, InstanceRegistry registry, ManagerBackups backups) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename)
|
||||
if (!GueltigerDateiname(filename))
|
||||
return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname."));
|
||||
|
||||
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
|
||||
var zipPath = Path.Combine(backupDir, filename);
|
||||
var pfad = Path.Combine(backups.BackupDirectory(instanz), filename);
|
||||
|
||||
if (!File.Exists(zipPath))
|
||||
if (!File.Exists(pfad))
|
||||
return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' nicht gefunden."));
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(zipPath);
|
||||
File.Delete(pfad);
|
||||
return Results.Ok(new OutcomeDto(true, $"Sicherungsdatei '{filename}' gelöscht."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return Results.BadRequest(new OutcomeDto(false, $"Konnte nicht gelöscht werden: {ex.Message}"));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── FTP-Upload der Sicherungen ───
|
||||
|
||||
verwaltung.MapGet("/sicherung/ftp", (ManagerBackups backups) =>
|
||||
{
|
||||
var s = backups.FtpSettings;
|
||||
|
||||
// Das Kennwort verlaesst den Manager nie - nur, ob eines gesetzt ist.
|
||||
return Results.Ok(new FtpSettingsDto(
|
||||
s.Enabled, s.Host, s.Port, s.Username, !string.IsNullOrEmpty(s.Password),
|
||||
s.RemoteDirectory, s.RequireTls, s.KeepCount));
|
||||
});
|
||||
|
||||
verwaltung.MapPut("/sicherung/ftp", (FtpSettingsUpdateDto neu, ManagerBackups backups) =>
|
||||
{
|
||||
if (neu.Enabled && string.IsNullOrWhiteSpace(neu.Host))
|
||||
return Results.BadRequest(new OutcomeDto(false, "Ohne Server lässt sich der Upload nicht einschalten."));
|
||||
|
||||
backups.UpdateFtp(new BackupFtpSettings
|
||||
{
|
||||
Enabled = neu.Enabled,
|
||||
Host = neu.Host.Trim(),
|
||||
Port = neu.Port is > 0 and < 65536 ? neu.Port : 21,
|
||||
Username = neu.Username.Trim(),
|
||||
RemoteDirectory = string.IsNullOrWhiteSpace(neu.RemoteDirectory) ? "/clawd-backups" : neu.RemoteDirectory.Trim(),
|
||||
RequireTls = neu.RequireTls,
|
||||
KeepCount = Math.Clamp(neu.KeepCount, 1, 365)
|
||||
}, neu.Password);
|
||||
|
||||
return Results.Ok(new OutcomeDto(true, "FTP-Einstellungen gespeichert."));
|
||||
});
|
||||
|
||||
verwaltung.MapPost("/sicherung/ftp/test", async (ManagerBackups backups, CancellationToken ct) =>
|
||||
{
|
||||
if (backups.Uploader is not { } uploader)
|
||||
return Results.BadRequest(new OutcomeDto(false, "Kein Upload eingerichtet."));
|
||||
|
||||
var problem = await uploader.TestAsync(ct);
|
||||
|
||||
return problem is null
|
||||
? Results.Ok(new OutcomeDto(true, "Verbindung zum FTP-Server steht (mit TLS)."))
|
||||
: Results.Ok(new OutcomeDto(false, problem));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Der Ereignisstrom ───
|
||||
@@ -389,44 +459,12 @@ internal static class ManagerEndpoints
|
||||
InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(),
|
||||
instanz.ProcessId, instanz.AutostartByManager);
|
||||
|
||||
private static ILogger Log(ILoggerFactory factory)
|
||||
=> factory.CreateLogger("ClawdDotNet.Manager.Web");
|
||||
|
||||
private static ManagedInstance? Find(InstanceRegistry registry, string id)
|
||||
=> registry.Instances.FirstOrDefault(
|
||||
i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Sicherungen dieser Instanz liegen.
|
||||
///
|
||||
/// <para><b>Eine unlesbare Instanzdatei wird gemeldet, nicht verschluckt.</b> Sonst
|
||||
/// landeten die Sicherungen wortlos im Standardverzeichnis statt im eingestellten —
|
||||
/// und niemand sähe, warum die alten verschwunden sind. Genau diese Bauart hat
|
||||
/// <c>manager.json</c> schon einmal eine halbe Stunde Fehlersuche gekostet.</para>
|
||||
/// </summary>
|
||||
private static string GetBackupDirectory(ManagedInstance instanz, ILogger? logger = null)
|
||||
{
|
||||
InstanceConfig? config = null;
|
||||
var settingsFile = Path.Combine(instanz.Directory, "InstanceSettings.json");
|
||||
if (File.Exists(settingsFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
config = JsonSerializer.Deserialize<InstanceConfig>(
|
||||
File.ReadAllText(settingsFile),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex,
|
||||
"InstanceSettings.json von {Name} ist nicht lesbar — für die Sicherungen "
|
||||
+ "gilt das Standardverzeichnis, nicht das dort eingetragene.",
|
||||
instanz.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return InstanceLayout.ResolveBackupDirectory(
|
||||
config ?? new InstanceConfig { WorkingDirectory = instanz.Directory },
|
||||
instanz.Directory);
|
||||
}
|
||||
private static bool GueltigerDateiname(string filename)
|
||||
=> filename.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
|
||||
&& Path.GetFileName(filename) == filename
|
||||
&& BackupNaming.IsBackupFile(filename);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
||||
/// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
|
||||
/// steht im Protokoll.</para>
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
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" })
|
||||
);
|
||||
|
||||
const neuBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
}, 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" })
|
||||
}, 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", {},
|
||||
const tbody = erstelleElement("tbody");
|
||||
|
||||
for (const b of backups) {
|
||||
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" })),
|
||||
erstelleElement("button", {
|
||||
klasse: "schaltflaeche klein gefahr flach",
|
||||
title: "Sicherungsdatei unwiderruflich löschen",
|
||||
onclick: () => oeffneLoeschenDialog(b.fileName)
|
||||
}, erstelleIcon(PFADE.muelleimer, 14))
|
||||
);
|
||||
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
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: "Zugangsdaten" }),
|
||||
erstelleElement("th", { text: "Schutz" }),
|
||||
erstelleElement("th", { text: "Dateien" }),
|
||||
erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" })
|
||||
)
|
||||
);
|
||||
erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" }))),
|
||||
tbody);
|
||||
|
||||
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",
|
||||
{
|
||||
klasse: "schaltflaeche klein sekundär",
|
||||
title: "Diese Sicherung wiederherstellen",
|
||||
onclick: () => oeffneRestoreDialog(b)
|
||||
},
|
||||
erstelleIcon(PFADE.abspielen, 14),
|
||||
erstelleElement("span", { text: " Wiederherstellen" })
|
||||
);
|
||||
|
||||
const deleteBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche klein gefahr flach",
|
||||
title: "Sicherungsdatei unwiderruflich löschen",
|
||||
onclick: () => oeffneLoeschenDialog(b.fileName)
|
||||
},
|
||||
erstelleIcon(PFADE.muelleimer, 14)
|
||||
);
|
||||
|
||||
aktionenTd.append(restoreBtn, deleteBtn);
|
||||
tr.append(dateiTd, datumTd, groesseTd, secretsTd, dateienTd, aktionenTd);
|
||||
tbody.append(tr);
|
||||
}
|
||||
|
||||
tabelle.append(thead, tbody);
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle])
|
||||
);
|
||||
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]));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Wie Sicherungsdateien heißen — und woran man erkennt, welche zu welcher Instanz gehört.
|
||||
///
|
||||
/// <para><b>Ein Präfix ist keine Grenze.</b> Das Aufräumen fragte bisher nur, ob ein Name
|
||||
/// mit <c>backup_<Instanz>_</c> beginnt. Bei zwei Instanzen, deren Namen ineinander
|
||||
/// liegen (<c>Test</c> und <c>Test_2</c>), 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.</para>
|
||||
/// </summary>
|
||||
public static class BackupNaming
|
||||
{
|
||||
public static string SafeName(string instanceName)
|
||||
=> PortableFileName.Sanitize(instanceName, "Instanz");
|
||||
|
||||
/// <summary>Sekundengenau, damit zwei Sicherungen von Hand in einer Minute nicht kollidieren.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Der Zeitpunkt aus dem Namen, oder <c>null</c>, wenn die Datei nicht zu dieser
|
||||
/// Instanz gehört. Kennt auch die älteren Schreibweisen: die der täglichen Sicherung
|
||||
/// in der Instanz (<c>yyyy-MM-dd_HHmm</c>) und die der ersten Verwaltungsansicht
|
||||
/// (<c>yyyyMMdd_HHmmss</c>).
|
||||
/// </summary>
|
||||
public static DateTime? TimestampOf(string fileName, string instanceName)
|
||||
{
|
||||
var muster = new Regex(
|
||||
"^backup_" + Regex.Escape(SafeName(instanceName)) + "_"
|
||||
+ @"(?:(?<d>\d{4}-\d{2}-\d{2})_(?<t>\d{4}(?:\d{2})?)|(?<d2>\d{8})_(?<t2>\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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wohin fertige Sicherungen zusätzlich gehen.</summary>
|
||||
public interface IBackupUploader
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
|
||||
/// <summary>Lädt hoch und räumt danach auf dem Ziel auf.</summary>
|
||||
Task UploadAsync(string localPath, string instanceName, int keepCount, CancellationToken ct);
|
||||
|
||||
/// <summary>Probiert die Verbindung. <c>null</c> heißt: geht.</summary>
|
||||
Task<string?> TestAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Upload per FTP.
|
||||
///
|
||||
/// <para><b>Hochgeladen wird unter einem vorläufigen Namen</b> 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.</para>
|
||||
///
|
||||
/// <para><b>TLS, wo es geht.</b> 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.</para>
|
||||
/// </summary>
|
||||
public sealed class FtpBackupUploader(Func<BackupFtpSettings> 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<string?> 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<AsyncFtpClient> 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);
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,10 @@
|
||||
<InternalsVisibleTo Include="ClawdDotNet.Manager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Upload fertiger Sicherungen. Dasselbe Paket und dieselbe Fassung wie im
|
||||
FTP-Werkzeug der Instanzen, damit es im Loesungsbaum nur eine gibt. -->
|
||||
<PackageReference Include="FluentFTP" Version="54.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Was bei einer Sicherung herausgekommen ist.</summary>
|
||||
/// <param name="UploadProblem">Warum der Upload nicht geklappt hat — oder <c>null</c>.</param>
|
||||
public sealed record BackupOutcome(
|
||||
string Path, string FileName, long SizeBytes, bool Uploaded, string? UploadProblem);
|
||||
|
||||
/// <summary>
|
||||
/// Die Sicherungen aller Instanzen, im Manager.
|
||||
///
|
||||
/// <para><b>Warum hier und nicht in der Instanz.</b> 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 <c>AppHost</c>); ihre
|
||||
/// Einstellungen — Schalter, Uhrzeit, Aufbewahrung, Zielordner — liest der Manager aus
|
||||
/// ihrer <c>InstanceSettings.json</c>.</para>
|
||||
///
|
||||
/// <para><b>Und deshalb dürfen jetzt auch die täglichen Sicherungen Zugangsdaten
|
||||
/// enthalten.</b> 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.</para>
|
||||
/// </summary>
|
||||
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<string, DateTime> _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);
|
||||
}
|
||||
|
||||
/// <summary>Der eigene Lizenzschlüssel, oder <c>null</c>, wenn keiner eingetragen ist.</summary>
|
||||
public string? LicenseKey
|
||||
=> string.IsNullOrWhiteSpace(_settings.Settings.LicenseKey) ? null : _settings.Settings.LicenseKey;
|
||||
|
||||
public IBackupUploader? Uploader => _uploader;
|
||||
|
||||
/// <summary>Die FTP-Einstellungen, wie sie gerade gelten.</summary>
|
||||
public BackupFtpSettings FtpSettings => _settings.Settings.BackupFtp;
|
||||
|
||||
/// <summary>
|
||||
/// Übernimmt neue FTP-Einstellungen und schreibt <c>manager.json</c>.
|
||||
///
|
||||
/// <para>Ein leeres Kennwort heißt „unverändert": Die Oberfläche bekommt das
|
||||
/// gespeicherte nie zu sehen und kann es deshalb auch nicht zurückschicken.</para>
|
||||
/// </summary>
|
||||
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 ───
|
||||
|
||||
/// <summary>
|
||||
/// Die Instanzdatei. Eine unlesbare wird gemeldet, nicht verschluckt — sonst landeten
|
||||
/// die Sicherungen wortlos im Standardverzeichnis statt im eingestellten.
|
||||
/// </summary>
|
||||
public InstanceConfig ReadConfig(ManagedInstance instanz)
|
||||
{
|
||||
var datei = Path.Combine(instanz.Directory, "InstanceSettings.json");
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(datei)
|
||||
&& JsonSerializer.Deserialize<InstanceConfig>(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 ───
|
||||
|
||||
/// <summary>Legt eine verschlüsselte Sicherung an, räumt auf und lädt hoch.</summary>
|
||||
public async Task<BackupOutcome> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Behält die neuesten <paramref name="keep"/> Sicherungen dieser Instanz.</summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Durchgang über alle Instanzen. Öffentlich, damit Tests ihn auslösen können.
|
||||
///
|
||||
/// <para><b>Einmal am Tag, auch über einen Neustart des Managers hinweg.</b> 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.</para>
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,15 @@ public sealed class ManagerSettings
|
||||
[JsonPropertyName("licenseKey")]
|
||||
public string LicenseKey { get; set; } = "";
|
||||
|
||||
// ─── Sicherungen ───
|
||||
|
||||
/// <summary>
|
||||
/// Wohin fertige Sicherungen zusätzlich hochgeladen werden. Aus, solange nichts
|
||||
/// eingetragen ist.
|
||||
/// </summary>
|
||||
[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 ""; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der FTP-Upload fertiger Sicherungen.
|
||||
///
|
||||
/// <para><b>Warum das vertretbar ist.</b> Eine Sicherung auf einem fremden Server ist nur
|
||||
/// deshalb unbedenklich, weil sie als Ganzes verschlüsselt ist
|
||||
/// (<c>LicenseBackupCipher</c>): 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 <see cref="RequireTls"/> erzwungen.</para>
|
||||
/// </summary>
|
||||
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; } = "";
|
||||
|
||||
/// <summary>Verschlüsselt gespeichert.</summary>
|
||||
[JsonPropertyName("password")]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
/// <summary>Darunter bekommt jede Instanz einen eigenen Ordner.</summary>
|
||||
[JsonPropertyName("remoteDirectory")]
|
||||
public string RemoteDirectory { get; set; } = "/clawd-backups";
|
||||
|
||||
/// <summary>
|
||||
/// Ohne TLS gar nicht verbinden. Aus heißt: TLS, wenn der Server es anbietet, sonst
|
||||
/// ohne — dann steht eine Warnung im Protokoll.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requireTls")]
|
||||
public bool RequireTls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keepCount")]
|
||||
public int KeepCount { get; set; } = 14;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsConfigured => Enabled && !string.IsNullOrWhiteSpace(Host);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Die Sicherung, verschlüsselt mit dem Lizenzschlüssel.
|
||||
///
|
||||
/// <para><b>Worum es geht.</b> 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.</para>
|
||||
///
|
||||
/// <para>Läuft in <see cref="SecretKeyCollection"/>: Der Maschinenwechsel wird über den
|
||||
/// statischen Schlüsselspeicher nachgestellt.</para>
|
||||
/// </summary>
|
||||
[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 ───
|
||||
|
||||
/// <summary>
|
||||
/// Mehr als ein Stück: Die Stückelung ist der Teil, an dem ein Fehler Daten
|
||||
/// verschluckt, ohne dass es auffällt.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein anderer Schlüssel wird am Kopf erkannt — bevor irgendetwas entschlüsselt und
|
||||
/// vor allem bevor eine Instanz dafür angehalten wird.
|
||||
/// </summary>
|
||||
[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<BackupKeyMismatchException>(() =>
|
||||
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), "ANDERE-LIZENZ"));
|
||||
|
||||
File.Exists(Datei("zurueck.bin")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Im Wiederherstellungsdialog tippt ein Mensch einen fremden Schlüssel ab. Ein
|
||||
/// Leerzeichen oder ein kleiner Buchstabe dürfen dort nicht über „verloren" entscheiden.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<BackupException>(() =>
|
||||
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<BackupException>(() =>
|
||||
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<string> SicherungAsync()
|
||||
{
|
||||
var ziel = Datei("backup_Alpha.clawdbak");
|
||||
|
||||
await new BackupService().CreateAsync(Instanz(), ziel, new BackupOptions
|
||||
{
|
||||
Secrets = SecretMode.License,
|
||||
LicenseKey = Lizenz
|
||||
});
|
||||
|
||||
return ziel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In der Datei steht nichts im Klartext — weder der Schlüssel noch, dass es überhaupt
|
||||
/// einen gibt.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mit fremdem Schlüssel wird nichts geschrieben — nicht halb, nicht teilweise.
|
||||
/// </summary>
|
||||
[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<BackupKeyMismatchException>(() =>
|
||||
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<BackupKeyMismatchException>(() =>
|
||||
new BackupService().InspectAsync(sicherung, licenseKey: "ANDERE-LIZENZ"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Die Sicherungen im Manager (#530): verschlüsselt mit dem Lizenzschlüssel, einmal am
|
||||
/// Tag, aufgeräumt, auf Wunsch hochgeladen.
|
||||
/// </summary>
|
||||
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<InstanceConfig>(File.ReadAllText(datei))!;
|
||||
config.AutoBackupEnabled = automatisch;
|
||||
config.AutoBackupTime = uhrzeit;
|
||||
config.BackupKeepCount = behalten;
|
||||
File.WriteAllText(datei, JsonSerializer.Serialize(config));
|
||||
|
||||
return pfad;
|
||||
}
|
||||
|
||||
private async Task<ManagerBackups> 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 ───
|
||||
|
||||
/// <summary>
|
||||
/// Ein Präfix ist keine Grenze: Die Archive von <c>Test_2</c> gehören nicht zu
|
||||
/// <c>Test</c> — sonst wirft das Aufräumen der einen die Sicherungen der anderen weg.
|
||||
/// </summary>
|
||||
[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<BackupException>(() =>
|
||||
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]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein gescheiterter Upload ist ein Befund, aber kein Grund, die Sicherung für
|
||||
/// misslungen zu erklären — sie liegt ja lokal.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<IReadOnlyList<ManagedInstance>> 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<string> 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<string?> TestAsync(CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <summary>Eine Uhr in UTC, damit „lokal" im Test überall dasselbe heißt.</summary>
|
||||
private sealed class FesteUhr(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Jetzt { get; set; } = start;
|
||||
public override DateTimeOffset GetUtcNow() => Jetzt;
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user