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>
118 lines
4.1 KiB
C#
118 lines
4.1 KiB
C#
using System.Text.Json.Serialization;
|
|
|
|
namespace ClawdDotNet.Core.Backup;
|
|
|
|
/// <summary>Wie mit Zugangsdaten in der Sicherung verfahren wird.</summary>
|
|
public enum SecretMode
|
|
{
|
|
/// <summary>
|
|
/// Zugangsdaten werden entfernt. Die Sicherung ist gefahrlos ablegbar, die
|
|
/// Wiederherstellung aber unvollständig — Schlüssel und Passwörter müssen
|
|
/// danach neu eingetragen werden.
|
|
/// </summary>
|
|
Exclude,
|
|
|
|
/// <summary>
|
|
/// Zugangsdaten werden mit einer Passphrase geschützt. Nur so überstehen sie
|
|
/// einen Rechner- oder Benutzerwechsel.
|
|
/// </summary>
|
|
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
|
|
{
|
|
public SecretMode Secrets { get; init; } = SecretMode.Exclude;
|
|
|
|
/// <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; }
|
|
|
|
/// <summary>Chatverläufe gehören zum Arbeitsstand, können aber umfangreich sein.</summary>
|
|
public bool IncludeChatHistory { get; init; } = true;
|
|
}
|
|
|
|
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; }
|
|
|
|
/// <summary>Vorhandene Dateien im Ziel überschreiben.</summary>
|
|
public bool Overwrite { get; init; }
|
|
}
|
|
|
|
public sealed record BackupEntry(
|
|
[property: JsonPropertyName("path")] string Path,
|
|
[property: JsonPropertyName("size")] long Size,
|
|
[property: JsonPropertyName("sha256")] string Sha256);
|
|
|
|
public sealed record BackupManifest
|
|
{
|
|
/// <summary>Erlaubt es späteren Fassungen, ältere Sicherungen zu erkennen.</summary>
|
|
[JsonPropertyName("formatVersion")]
|
|
public int FormatVersion { get; init; } = 1;
|
|
|
|
[JsonPropertyName("createdAt")]
|
|
public DateTime CreatedAt { get; init; }
|
|
|
|
[JsonPropertyName("instanceId")]
|
|
public string InstanceId { get; init; } = "";
|
|
|
|
[JsonPropertyName("instanceName")]
|
|
public string InstanceName { get; init; } = "";
|
|
|
|
[JsonPropertyName("secrets")]
|
|
public string Secrets { get; init; } = nameof(SecretMode.Exclude);
|
|
|
|
/// <summary>Wie viele Zugangsdaten enthalten bzw. entfernt wurden.</summary>
|
|
[JsonPropertyName("secretCount")]
|
|
public int SecretCount { get; init; }
|
|
|
|
[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 is nameof(SecretMode.Passphrase) or nameof(SecretMode.License);
|
|
}
|
|
|
|
public sealed record BackupResult(string ZipPath, BackupManifest Manifest, long SizeBytes);
|
|
|
|
public sealed record RestoreResult(
|
|
IReadOnlyList<string> Written,
|
|
IReadOnlyList<string> Skipped,
|
|
IReadOnlyList<string> WouldOverwrite)
|
|
{
|
|
public bool HasConflicts => WouldOverwrite.Count > 0;
|
|
}
|
|
|
|
/// <summary>Nicht versiegelt: <see cref="BackupKeyMismatchException"/> ist ein Sonderfall davon.</summary>
|
|
public class BackupException(string message, Exception? inner = null)
|
|
: Exception(message, inner);
|