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
+196
View File
@@ -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_&lt;Instanz&gt;_</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>
+296
View File
@@ -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);
}