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
@@ -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]));
}