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
+172 -134
View File
@@ -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)."));
// Ohne das Token der Anfrage: Wer den Browser schliesst, soll die
// Sicherung nicht mitten im Schreiben abbrechen.
var ergebnis = await backups.CreateAsync(
instanz, req?.IncludeLogs ?? false, req?.IncludeChatHistory ?? true,
CancellationToken.None);
var upload = ergebnis switch
{
{ Uploaded: true } => " Auf den FTP-Server hochgeladen.",
{ UploadProblem: { } problem } => $" Der Upload ist gescheitert: {problem}",
_ => ""
};
return Results.Ok(new OutcomeDto(true,
$"Sicherung '{ergebnis.FileName}' erstellt ({ergebnis.SizeBytes / 1024} KB).{upload}"));
}
catch (Exception ex)
catch (BackupException ex)
{
return Results.BadRequest(new OutcomeDto(false, $"Sicherung fehlgeschlagen: {ex.Message}"));
return Results.BadRequest(new OutcomeDto(false, ex.Message));
}
});
verwaltung.MapPost("/instanzen/{id}/backups/{filename}/restore", async (
string id, string filename, RestoreBackupRequestDto? req, ManagerCore manager,
InstanceRegistry registry, ILoggerFactory protokoll) =>
InstanceRegistry registry, ManagerBackups backups) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename)
if (!GueltigerDateiname(filename))
return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname."));
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
var zipPath = Path.Combine(backupDir, filename);
var pfad = Path.Combine(backups.BackupDirectory(instanz), filename);
if (!File.Exists(zipPath))
if (!File.Exists(pfad))
return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' wurde nicht gefunden."));
string? schluessel = null;
if (LicenseBackupCipher.IsEncrypted(pfad))
{
var angegeben = !string.IsNullOrWhiteSpace(req?.Lizenzschluessel);
schluessel = angegeben ? req!.Lizenzschluessel : backups.LicenseKey;
// Die Probe VOR dem Anhalten. Wer erst anhaelt und dann merkt, dass der
// Schluessel nicht passt, hat eine laufende Instanz fuer nichts gestoppt.
if (schluessel is null || !LicenseBackupCipher.MatchesKey(pfad, schluessel))
{
return Results.Json(new KeyNeededDto(false,
angegeben
? "Der angegebene Lizenzschlüssel passt nicht zu dieser Sicherung."
: "Diese Sicherung stammt von einer anderen Installation. Zum Zurückspielen "
+ "wird deren Lizenzschlüssel gebraucht.",
"fremde_lizenz"), statusCode: StatusCodes.Status422UnprocessableEntity);
}
}
// Ausdruecklich ohne das Token der Anfrage: Wer mitten im Zurueckspielen den
// Browser schliesst, hinterliesse sonst ein halb ueberschriebenes
// Instanzverzeichnis - und das wuerde anschliessend auch noch gestartet. Was
// hier beginnt, wird zu Ende gebracht.
// Instanzverzeichnis - und das wuerde anschliessend auch noch gestartet.
var ct = CancellationToken.None;
var wasRunning = instanz.Condition is InstanceCondition.Running or InstanceCondition.Silent;
if (wasRunning)
var liefVorher = instanz.Condition is InstanceCondition.Running or InstanceCondition.Silent;
if (liefVorher && !await manager.StopInstanceAsync(instanz.Directory, ct))
{
var stopped = await manager.StopInstanceAsync(instanz.Directory, ct);
if (!stopped)
{
return Results.Json(
new OutcomeDto(false, $"Instanz '{instanz.Name}' konnte vor der Wiederherstellung nicht angehalten werden."),
statusCode: StatusCodes.Status409Conflict);
}
return Results.Json(
new OutcomeDto(false, $"Instanz '{instanz.Name}' konnte vor der Wiederherstellung nicht angehalten werden."),
statusCode: StatusCodes.Status409Conflict);
}
try
{
var service = new BackupService();
var restoreResult = await service.RestoreAsync(
zipPath,
instanz.Directory,
var ergebnis = await new BackupService().RestoreAsync(pfad, instanz.Directory,
new RestoreOptions
{
Passphrase = req?.Passphrase,
LicenseKey = schluessel,
Overwrite = true
},
ct);
}, ct);
if (wasRunning)
{
if (liefVorher)
await manager.StartInstanceAsync(instanz.Directory, ct);
}
return Results.Ok(new OutcomeDto(true, $"Sicherung '{filename}' erfolgreich wiederhergestellt ({restoreResult.Written.Count} Dateien geschrieben)."));
return Results.Ok(new OutcomeDto(true,
$"Sicherung '{filename}' wiederhergestellt ({ergebnis.Written.Count} Dateien geschrieben)."));
}
catch (Exception ex)
{
if (wasRunning)
if (liefVorher)
{
try { await manager.StartInstanceAsync(instanz.Directory, ct); } catch { }
try { await manager.StartInstanceAsync(instanz.Directory, ct); }
catch { /* Der eigentliche Fehler ist der, der gemeldet wird. */ }
}
return Results.BadRequest(new OutcomeDto(false, $"Wiederherstellung fehlgeschlagen: {ex.Message}"));
@@ -316,30 +344,72 @@ internal static class ManagerEndpoints
});
verwaltung.MapDelete("/instanzen/{id}/backups/{filename}", (
string id, string filename, InstanceRegistry registry, ILoggerFactory protokoll) =>
string id, string filename, InstanceRegistry registry, ManagerBackups backups) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename)
if (!GueltigerDateiname(filename))
return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname."));
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
var zipPath = Path.Combine(backupDir, filename);
var pfad = Path.Combine(backups.BackupDirectory(instanz), filename);
if (!File.Exists(zipPath))
if (!File.Exists(pfad))
return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' nicht gefunden."));
try
{
File.Delete(zipPath);
File.Delete(pfad);
return Results.Ok(new OutcomeDto(true, $"Sicherungsdatei '{filename}' gelöscht."));
}
catch (Exception ex)
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return Results.BadRequest(new OutcomeDto(false, $"Konnte nicht gelöscht werden: {ex.Message}"));
}
});
// ─── FTP-Upload der Sicherungen ───
verwaltung.MapGet("/sicherung/ftp", (ManagerBackups backups) =>
{
var s = backups.FtpSettings;
// Das Kennwort verlaesst den Manager nie - nur, ob eines gesetzt ist.
return Results.Ok(new FtpSettingsDto(
s.Enabled, s.Host, s.Port, s.Username, !string.IsNullOrEmpty(s.Password),
s.RemoteDirectory, s.RequireTls, s.KeepCount));
});
verwaltung.MapPut("/sicherung/ftp", (FtpSettingsUpdateDto neu, ManagerBackups backups) =>
{
if (neu.Enabled && string.IsNullOrWhiteSpace(neu.Host))
return Results.BadRequest(new OutcomeDto(false, "Ohne Server lässt sich der Upload nicht einschalten."));
backups.UpdateFtp(new BackupFtpSettings
{
Enabled = neu.Enabled,
Host = neu.Host.Trim(),
Port = neu.Port is > 0 and < 65536 ? neu.Port : 21,
Username = neu.Username.Trim(),
RemoteDirectory = string.IsNullOrWhiteSpace(neu.RemoteDirectory) ? "/clawd-backups" : neu.RemoteDirectory.Trim(),
RequireTls = neu.RequireTls,
KeepCount = Math.Clamp(neu.KeepCount, 1, 365)
}, neu.Password);
return Results.Ok(new OutcomeDto(true, "FTP-Einstellungen gespeichert."));
});
verwaltung.MapPost("/sicherung/ftp/test", async (ManagerBackups backups, CancellationToken ct) =>
{
if (backups.Uploader is not { } uploader)
return Results.BadRequest(new OutcomeDto(false, "Kein Upload eingerichtet."));
var problem = await uploader.TestAsync(ct);
return problem is null
? Results.Ok(new OutcomeDto(true, "Verbindung zum FTP-Server steht (mit TLS)."))
: Results.Ok(new OutcomeDto(false, problem));
});
}
// ─── Der Ereignisstrom ───
@@ -389,44 +459,12 @@ internal static class ManagerEndpoints
InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(),
instanz.ProcessId, instanz.AutostartByManager);
private static ILogger Log(ILoggerFactory factory)
=> factory.CreateLogger("ClawdDotNet.Manager.Web");
private static ManagedInstance? Find(InstanceRegistry registry, string id)
=> registry.Instances.FirstOrDefault(
i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase));
/// <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",
{
klasse: "schaltflaeche flach",
title: "Sicherungen neu laden",
onclick: () => ladeSicherungen()
},
erstelleIcon(PFADE.aktualisieren, 16),
erstelleElement("span", { text: " Aktualisieren" })
kopf.append(
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" })),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("button", {
klasse: "schaltflaeche flach",
title: "Sicherungen neu laden",
disabled: !instanzId,
onclick: () => ladeSicherungen()
}, erstelleIcon(PFADE.aktualisieren, 16), erstelleElement("span", { text: " Aktualisieren" })),
erstelleElement("button", {
klasse: "schaltflaeche primaer",
title: "Neue Sicherung erstellen",
disabled: !instanzId,
onclick: () => oeffneSicherungsDialog()
}, erstelleIcon(PFADE.plus, 16), erstelleElement("span", { text: " Neue Sicherung" }))
)
);
const neuBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
title: "Neue Sicherung erstellen",
disabled: !instanzId,
onclick: () => oeffneSicherungsDialog()
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neue Sicherung" })
);
aktionenGruppe.append(aktualisierenBtn, neuBtn);
kopf.append(titelGruppe, aktionenGruppe);
layout.append(kopf);
const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" });
const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" });
const ftpBereich = erstelleElement("div", { klasse: "sicherung-ftp-bereich" });
if (!instanzId) {
layout.append(
inhaltBereich.append(
erstelleKarte("Keine Instanz ausgewählt", [
erstelleElement("p", {
klasse: "hinweis",
@@ -79,34 +94,37 @@ export async function rendereSicherung() {
})
])
);
container.append(layout);
return;
}
// Verzeichnis-Info & Inhalt
const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" });
const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" });
layout.append(infoBereich, inhaltBereich);
layout.append(infoBereich, inhaltBereich, ftpBereich);
container.append(layout);
// Der FTP-Upload gilt für alle Instanzen und ist deshalb auch ohne Auswahl da.
rendereFtp(ftpBereich);
if (!instanzId) return;
// ─── Liste ───
async function ladeSicherungen() {
inhaltBereich.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Sicherungen…" })
);
try {
const daten = await api(`/verwaltung/instanzen/${instanzId}/backups`);
const verzeichnis = daten?.directory ?? "—";
const daten = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`);
const backups = daten?.backups ?? [];
infoBereich.replaceChildren(
erstelleKarte("Sicherungsverzeichnis", [
erstelleElement("p", { klasse: "gedaempft", text: "Sicherungen werden komprimiert als ZIP-Archiv abgelegt. Die Datenbank (state.db) wird konsistent per VACUUM INTO kopiert." }),
erstelleElement("p", {
klasse: "gedaempft",
text: "Sicherungen werden als Ganzes mit dem Lizenzschlüssel des Instanzmanagers "
+ "verschlüsselt und enthalten die Zugangsdaten. Auf einer anderen Maschine lassen "
+ "sie sich mit demselben Lizenzschlüssel zurückspielen."
}),
erstelleElement("div", { klasse: "ordner-pfad-anzeige" },
erstelleIcon(PFADE.server, 14),
erstelleElement("code", { text: verzeichnis })
)
erstelleElement("code", { text: daten?.directory ?? "—" }))
])
);
@@ -115,86 +133,53 @@ export async function rendereSicherung() {
erstelleKarte("Vorhandene Sicherungen", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Für diese Instanz sind noch keine Sicherungsarchive vorhanden. Erstelle über die Schaltfläche oben eine neue Sicherung."
text: "Für diese Instanz gibt es noch keine Sicherung. Lege über die Schaltfläche oben eine an."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" });
const thead = erstelleElement("thead", {},
erstelleElement("tr", {},
erstelleElement("th", { text: "Archivdatei" }),
erstelleElement("th", { text: "Erstellt am" }),
erstelleElement("th", { text: "Größe" }),
erstelleElement("th", { text: "Zugangsdaten" }),
erstelleElement("th", { text: "Dateien" }),
erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" })
)
);
const tbody = erstelleElement("tbody");
for (const b of backups) {
const tr = erstelleElement("tr");
// Dateiname & Icon
const dateiTd = erstelleElement("td", { klasse: "dateiname-zelle" },
erstelleIcon(PFADE.sicherung, 16),
erstelleElement("strong", { text: b.fileName })
);
// Datum
const datumTd = erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) });
// Größe
const groesseTd = erstelleElement("td", { text: formatiereBytes(b.sizeBytes) });
// Zugangsdaten Modus
const hatPassphrase = b.secrets === "Passphrase";
const secretsPlakette = erstellePlakette(
hatPassphrase ? `Passphrase (${b.secretCount})` : "Ausgeschlossen",
hatPassphrase ? "erfolg" : "neutral"
);
const secretsTd = erstelleElement("td", {}, secretsPlakette);
// Dateienanzahl
const dateienTd = erstelleElement("td", { text: b.fileCount > 0 ? `${b.fileCount} Dateien` : "—" });
// Aktionen
const aktionenTd = erstelleElement("td", { klasse: "spalte-aktionen" });
const restoreBtn = erstelleElement(
"button",
{
const aktionen = erstelleElement("td", { klasse: "spalte-aktionen" },
erstelleElement("button", {
klasse: "schaltflaeche klein sekundär",
title: "Diese Sicherung wiederherstellen",
disabled: b.secrets === "Unlesbar",
onclick: () => oeffneRestoreDialog(b)
},
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Wiederherstellen" })
);
const deleteBtn = erstelleElement(
"button",
{
}, erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " Wiederherstellen" })),
erstelleElement("button", {
klasse: "schaltflaeche klein gefahr flach",
title: "Sicherungsdatei unwiderruflich löschen",
onclick: () => oeffneLoeschenDialog(b.fileName)
},
erstelleIcon(PFADE.muelleimer, 14)
}, erstelleIcon(PFADE.muelleimer, 14))
);
aktionenTd.append(restoreBtn, deleteBtn);
tr.append(dateiTd, datumTd, groesseTd, secretsTd, dateienTd, aktionenTd);
tbody.append(tr);
tbody.append(erstelleElement("tr", {},
erstelleElement("td", { klasse: "dateiname-zelle" },
erstelleIcon(PFADE.sicherung, 16), erstelleElement("strong", { text: b.fileName })),
erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) }),
erstelleElement("td", { text: formatiereBytes(b.sizeBytes) }),
erstelleElement("td", {}, schutzPlakette(b)),
erstelleElement("td", { text: b.fileCount > 0 ? `${b.fileCount} Dateien` : "—" }),
aktionen
));
}
tabelle.append(thead, tbody);
inhaltBereich.replaceChildren(
erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle])
);
const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" },
erstelleElement("thead", {},
erstelleElement("tr", {},
erstelleElement("th", { text: "Archivdatei" }),
erstelleElement("th", { text: "Erstellt am" }),
erstelleElement("th", { text: "Größe" }),
erstelleElement("th", { text: "Schutz" }),
erstelleElement("th", { text: "Dateien" }),
erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" }))),
tbody);
inhaltBereich.replaceChildren(erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle]));
} catch (err) {
inhaltBereich.replaceChildren(
erstelleKarte("Fehler", [
@@ -204,92 +189,40 @@ export async function rendereSicherung() {
}
}
// ─── Modal: Neue Sicherung erstellen ───
// ─── Neue Sicherung ───
function oeffneSicherungsDialog() {
const form = erstelleElement("form", { klasse: "formular" });
const chats = checkbox("backup-chk-chats", "Chatverläufe sichern", true);
const logs = checkbox("backup-chk-logs", "Protokolldateien (Logs) einschließen", false);
// Modus Zugangsdaten
const secretsGruppe = erstelleElement("div", { klasse: "form-gruppe" });
secretsGruppe.append(
erstelleElement("label", { text: "Zugangsdaten & Schlüssel" }),
erstelleElement("p", { klasse: "form-hilfe", text: "DPAPI-geschützte Daten können mit einer Passphrase geschützt oder sicherheitshalber entfernt werden." })
form.append(
erstelleElement("p", {
klasse: "form-hilfe",
text: "Die Sicherung wird mit dem Lizenzschlüssel des Instanzmanagers verschlüsselt und "
+ "enthält die Zugangsdaten. Ist ein FTP-Upload eingerichtet, wird sie anschließend hochgeladen."
}),
chats.gruppe,
logs.gruppe
);
const secretsSelect = erstelleElement("select", { klasse: "eingabe" });
const optExclude = erstelleElement("option", { value: "Exclude", text: "Entfernen (gefahrlos ablegbar, Schlüssel müssen neu eingetragen werden)" });
const optPassphrase = erstelleElement("option", { value: "Passphrase", text: "Mit Passphrase schützen (vollständig übertragbar)" });
secretsSelect.append(optExclude, optPassphrase);
secretsGruppe.append(secretsSelect);
// Passphrase Feld
const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { display: "none" } });
const passInput = erstelleElement("input", {
type: "password",
klasse: "eingabe",
placeholder: "Passphrase für Verschlüsselung eingeben…"
});
passGruppe.append(
erstelleElement("label", { text: "Passphrase" }),
passInput
);
secretsSelect.addEventListener("change", () => {
passGruppe.style.display = secretsSelect.value === "Passphrase" ? "block" : "none";
if (secretsSelect.value === "Passphrase") passInput.focus();
});
// Checkboxen
const chatsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
const chatsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-chats", checked: true });
const chatsLabel = erstelleElement("label", { for: "backup-chk-chats", text: "Chatverläufe sichern" });
chatsGruppe.append(chatsCheckbox, chatsLabel);
const logsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
const logsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-logs", checked: false });
const logsLabel = erstelleElement("label", { for: "backup-chk-logs", text: "Protokolldateien (Logs) einschließen" });
logsGruppe.append(logsCheckbox, logsLabel);
form.append(secretsGruppe, passGruppe, chatsGruppe, logsGruppe);
let modal;
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche",
text: "Abbrechen",
onclick: () => modal.schliessen()
});
const speichernBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Sicherung erstellen"
type: "submit", klasse: "schaltflaeche primaer", text: "Sicherung erstellen"
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const secrets = secretsSelect.value;
const passphrase = passInput.value.trim();
if (secrets === "Passphrase" && !passphrase) {
zeigeMeldung("Bitte gib eine Passphrase ein.", "warnung");
passInput.focus();
return;
}
speichernBtn.disabled = true;
speichernBtn.textContent = "Erstelle Sicherung…";
try {
const res = await api(`/verwaltung/instanzen/${instanzId}/backups`, {
const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`, {
method: "POST",
body: {
secrets,
passphrase: passphrase || null,
includeLogs: logsCheckbox.checked,
includeChatHistory: chatsCheckbox.checked
}
body: { includeLogs: logs.box.checked, includeChatHistory: chats.box.checked }
});
zeigeMeldung(res.meldung || "Sicherung erfolgreich erstellt.", "erfolg");
// Ein gescheiterter Upload steht in der Meldung; die Sicherung liegt trotzdem lokal.
const warnt = (res.meldung || "").includes("gescheitert");
zeigeMeldung(res.meldung || "Sicherung erstellt.", warnt ? "warnung" : "erfolg", warnt ? 8000 : undefined);
modal.schliessen();
ladeSicherungen();
} catch (err) {
@@ -299,15 +232,27 @@ export async function rendereSicherung() {
}
});
modal = erstelleModal("Neue Sicherung erstellen", form, [abbrechenBtn, speichernBtn]);
modal = erstelleModal("Neue Sicherung erstellen", form, [
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen()
}),
speichernBtn
]);
modal.oeffnen();
}
// ─── Modal: Wiederherstellen ───
// ─── Wiederherstellen ───
async function stelleWiederHer(backup, { passphrase = null, lizenzschluessel = null } = {}) {
return api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(backup.fileName)}/restore`, {
method: "POST",
body: { passphrase, lizenzschluessel }
});
}
function oeffneRestoreDialog(backup) {
const form = erstelleElement("form", { klasse: "formular" });
const warnung = erstelleElement("div", { klasse: "hinweis-box warnung" },
form.append(erstelleElement("div", { klasse: "hinweis-box warnung" },
erstelleIcon(PFADE.ausrufezeichen, 18),
erstelleElement("div", {},
erstelleElement("strong", { text: "Achtung: Überschreiben bestehender Daten" }),
@@ -315,50 +260,30 @@ export async function rendereSicherung() {
text: `Die Wiederherstellung überschreibt die aktuellen Konfigurationen und Daten der Instanz mit dem Stand vom ${formatiereDatumZeit(backup.createdAt)}.`
}),
erstelleElement("p", {
text: "Die Instanz wird dafür kurz geordnet gestoppt und nach erfolgreicher Rücksicherung wieder hochgefahren."
})
)
);
form.append(warnung);
text: "Die Instanz wird dafür geordnet angehalten und danach wieder gestartet."
}))));
// Nur ältere, unverschlüsselte Sicherungen mit Passphrase brauchen sie noch.
let passInput = null;
if (backup.secrets === "Passphrase") {
const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { marginTop: "16px" } });
if (backup.secrets === "Passphrase" && !backup.verschluesselt) {
passInput = erstelleElement("input", {
type: "password",
klasse: "eingabe",
placeholder: "Passphrase der Sicherung eingeben…",
required: true
type: "password", klasse: "eingabe", placeholder: "Passphrase der Sicherung…", required: true
});
passGruppe.append(
erstelleElement("label", { text: "Passphrase für Zugangsdaten" }),
passInput
);
form.append(passGruppe);
form.append(feld("Passphrase für Zugangsdaten", passInput));
}
let modal;
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche",
text: "Abbrechen",
onclick: () => modal.schliessen()
});
const restoreBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche warnung",
text: "Jetzt Wiederherstellen"
type: "submit", klasse: "schaltflaeche warnung", text: "Jetzt wiederherstellen"
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const passphrase = passInput?.value?.trim() || null;
if (backup.secrets === "Passphrase" && !passphrase) {
if (passInput && !passphrase) {
zeigeMeldung("Bitte gib die Passphrase an.", "warnung");
passInput?.focus();
passInput.focus();
return;
}
@@ -366,38 +291,94 @@ export async function rendereSicherung() {
restoreBtn.textContent = "Stelle wieder her…";
try {
const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(backup.fileName)}/restore`, {
method: "POST",
body: { passphrase }
});
zeigeMeldung(res.meldung || "Wiederherstellung erfolgreich abgeschlossen.", "erfolg");
const res = await stelleWiederHer(backup, { passphrase });
zeigeMeldung(res.meldung || "Wiederherstellung abgeschlossen.", "erfolg");
modal.schliessen();
ladeSicherungen();
} catch (err) {
if (err.code === "fremde_lizenz") {
// Der eigene Schlüssel passt nicht - die Sicherung stammt von einer anderen
// Installation. Angehalten wurde dafür nichts; das prüft der Server vorher.
modal.schliessen();
frageLizenzschluessel(backup, err.message);
return;
}
zeigeMeldung(`Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr");
restoreBtn.disabled = false;
restoreBtn.textContent = "Jetzt Wiederherstellen";
restoreBtn.textContent = "Jetzt wiederherstellen";
}
});
modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [abbrechenBtn, restoreBtn]);
modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen()
}),
restoreBtn
]);
modal.oeffnen();
}
// ─── Modal: Löschen ───
function oeffneLoeschenDialog(fileName) {
const text = erstelleElement("p", {
text: `Soll die Sicherungsdatei '${fileName}' wirklich unwiderruflich gelöscht werden?`
/**
* Das Fenster, das nach dem Lizenzschlüssel der Quellinstallation fragt.
* Erscheint, wenn der eigene Schlüssel die Sicherung nicht öffnet.
*/
function frageLizenzschluessel(backup, grund) {
const form = erstelleElement("form", { klasse: "formular" });
const eingabe = erstelleElement("input", {
type: "password",
klasse: "eingabe",
placeholder: "Lizenzschlüssel der Installation, von der die Sicherung stammt…",
autocomplete: "off",
required: true
});
form.append(
erstelleElement("p", { text: grund }),
feld("Lizenzschlüssel der Quelle", eingabe,
"Groß- und Kleinschreibung sowie Leerzeichen spielen keine Rolle.")
);
let modal;
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche",
text: "Abbrechen",
onclick: () => modal.schliessen()
const okBtn = erstelleElement("button", {
type: "submit", klasse: "schaltflaeche warnung", text: "Mit diesem Schlüssel wiederherstellen"
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const lizenzschluessel = eingabe.value.trim();
if (!lizenzschluessel) { eingabe.focus(); return; }
okBtn.disabled = true;
okBtn.textContent = "Stelle wieder her…";
try {
const res = await stelleWiederHer(backup, { lizenzschluessel });
zeigeMeldung(res.meldung || "Wiederherstellung abgeschlossen.", "erfolg");
modal.schliessen();
ladeSicherungen();
} catch (err) {
zeigeMeldung(err.code === "fremde_lizenz"
? "Dieser Lizenzschlüssel passt nicht zu der Sicherung."
: `Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr");
okBtn.disabled = false;
okBtn.textContent = "Mit diesem Schlüssel wiederherstellen";
eingabe.select();
}
});
modal = erstelleModal("Lizenzschlüssel der Quelle erforderlich", form, [
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen()
}),
okBtn
]);
modal.oeffnen();
eingabe.focus();
}
// ─── Löschen ───
function oeffneLoeschenDialog(fileName) {
let modal;
const loeschenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche gefahr",
@@ -405,7 +386,7 @@ export async function rendereSicherung() {
onclick: async () => {
loeschenBtn.disabled = true;
try {
const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(fileName)}`, {
const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(fileName)}`, {
method: "DELETE"
});
zeigeMeldung(res.meldung || "Sicherung gelöscht.", "erfolg");
@@ -418,9 +399,113 @@ export async function rendereSicherung() {
}
});
modal = erstelleModal("Sicherung löschen", text, [abbrechenBtn, loeschenBtn]);
modal = erstelleModal("Sicherung löschen",
erstelleElement("p", { text: `Soll die Sicherungsdatei '${fileName}' wirklich unwiderruflich gelöscht werden?` }),
[
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen()
}),
loeschenBtn
]);
modal.oeffnen();
}
ladeSicherungen();
}
// ─── FTP-Upload (für alle Instanzen) ───
async function rendereFtp(bereich) {
bereich.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade FTP-Einstellungen…" }));
let stand;
try {
stand = await api("/verwaltung/sicherung/ftp");
} catch (err) {
bereich.replaceChildren(erstelleKarte("FTP-Upload", [
erstelleElement("p", { klasse: "fehler-meldung", text: `Einstellungen nicht ladbar: ${err.message}` })
]));
return;
}
const form = erstelleElement("form", { klasse: "formular" });
const an = checkbox("ftp-an", "Fertige Sicherungen automatisch hochladen", stand.enabled);
const host = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.host ?? "", placeholder: "ftp.example.org" });
const port = erstelleElement("input", { type: "number", klasse: "eingabe", value: String(stand.port ?? 21), min: "1", max: "65535" });
const benutzer = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.username ?? "", autocomplete: "off" });
const kennwort = erstelleElement("input", {
type: "password",
klasse: "eingabe",
autocomplete: "new-password",
placeholder: stand.kennwortGesetzt ? "(gespeichert — leer lassen für unverändert)" : "Kennwort"
});
const ordner = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.remoteDirectory ?? "/clawd-backups" });
const behalten = erstelleElement("input", { type: "number", klasse: "eingabe", value: String(stand.keepCount ?? 14), min: "1", max: "365" });
const tls = checkbox("ftp-tls", "Nur mit TLS verbinden", stand.requireTls);
form.append(
erstelleElement("p", {
klasse: "form-hilfe",
text: "Die Sicherungen sind verschlüsselt — wer den Server liest, liest nichts Brauchbares. "
+ "Das Kennwort des Servers geht ohne TLS aber im Klartext über die Leitung."
}),
an.gruppe,
feld("Server", host),
feld("Port", port),
feld("Benutzer", benutzer),
feld("Kennwort", kennwort),
feld("Zielordner", ordner, "Darunter bekommt jede Instanz einen eigenen Ordner."),
feld("Sicherungen je Instanz behalten", behalten, "Ältere werden auf dem Server gelöscht."),
tls.gruppe
);
const speichernBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: "Speichern" });
const testBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche",
text: "Verbindung testen",
onclick: async () => {
testBtn.disabled = true;
try {
const res = await api("/verwaltung/sicherung/ftp/test", { method: "POST" });
zeigeMeldung(res.meldung, res.ok ? "erfolg" : "warnung", 8000);
} catch (err) {
zeigeMeldung(`Test fehlgeschlagen: ${err.message}`, "gefahr");
} finally {
testBtn.disabled = false;
}
}
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
speichernBtn.disabled = true;
try {
const res = await api("/verwaltung/sicherung/ftp", {
method: "PUT",
body: {
enabled: an.box.checked,
host: host.value.trim(),
port: Number(port.value) || 21,
username: benutzer.value.trim(),
password: kennwort.value || null,
remoteDirectory: ordner.value.trim(),
requireTls: tls.box.checked,
keepCount: Number(behalten.value) || 14
}
});
zeigeMeldung(res.meldung || "Gespeichert.", "erfolg");
kennwort.value = "";
rendereFtp(bereich);
} catch (err) {
zeigeMeldung(`Speichern fehlgeschlagen: ${err.message}`, "gefahr");
} finally {
speichernBtn.disabled = false;
}
});
form.append(erstelleElement("div", { klasse: "gruppe-horizontal" }, testBtn, speichernBtn));
bereich.replaceChildren(erstelleKarte("FTP-Upload der Sicherungen", [form]));
}