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:
Richard
2026-09-11 11:03:25 +02:00
co-authored by Claude Opus 5
parent 99f8a89fd0
commit fb185ca84d
17 changed files with 2140 additions and 389 deletions
@@ -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);
}