Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js
T
RichardandClaude Opus 5 fb185ca84d 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>
2026-09-11 11:03:25 +02:00

512 lines
19 KiB
JavaScript

// ClawdDotNet — Datensicherung & Wiederherstellung (#530)
//
// 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";
import {
erstelleElement,
erstelleKarte,
erstellePlakette,
erstelleIcon,
erstelleModal,
PFADE,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
function formatiereBytes(bytes) {
if (bytes === 0) return "0 B";
if (!bytes) return "—";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
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" });
// ─── Kopf ───
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
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" }))
)
);
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) {
inhaltBereich.append(
erstelleKarte("Keine Instanz ausgewählt", [
erstelleElement("p", {
klasse: "hinweis",
text: "Bitte wähle oben im Kopfbereich eine Instanz aus, um deren Sicherungen zu verwalten."
})
])
);
}
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/${pfadInstanz}/backups`);
const backups = daten?.backups ?? [];
infoBereich.replaceChildren(
erstelleKarte("Sicherungsverzeichnis", [
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: daten?.directory ?? "—" }))
])
);
if (backups.length === 0) {
inhaltBereich.replaceChildren(
erstelleKarte("Vorhandene Sicherungen", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Für diese Instanz gibt es noch keine Sicherung. Lege über die Schaltfläche oben eine an."
})
])
);
return;
}
const tbody = erstelleElement("tbody");
for (const b of backups) {
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" })),
erstelleElement("button", {
klasse: "schaltflaeche klein gefahr flach",
title: "Sicherungsdatei unwiderruflich löschen",
onclick: () => oeffneLoeschenDialog(b.fileName)
}, erstelleIcon(PFADE.muelleimer, 14))
);
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
));
}
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", [
erstelleElement("p", { klasse: "fehler-meldung", text: `Sicherungen konnten nicht geladen werden: ${err.message}` })
])
);
}
}
// ─── 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);
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
);
let modal;
const speichernBtn = erstelleElement("button", {
type: "submit", klasse: "schaltflaeche primaer", text: "Sicherung erstellen"
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
speichernBtn.disabled = true;
speichernBtn.textContent = "Erstelle Sicherung…";
try {
const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`, {
method: "POST",
body: { includeLogs: logs.box.checked, includeChatHistory: chats.box.checked }
});
// 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) {
zeigeMeldung(`Fehler beim Sichern: ${err.message}`, "gefahr");
speichernBtn.disabled = false;
speichernBtn.textContent = "Sicherung erstellen";
}
});
modal = erstelleModal("Neue Sicherung erstellen", form, [
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen()
}),
speichernBtn
]);
modal.oeffnen();
}
// ─── 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" });
form.append(erstelleElement("div", { klasse: "hinweis-box warnung" },
erstelleIcon(PFADE.ausrufezeichen, 18),
erstelleElement("div", {},
erstelleElement("strong", { text: "Achtung: Überschreiben bestehender Daten" }),
erstelleElement("p", {
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 geordnet angehalten und danach wieder gestartet."
}))));
// Nur ältere, unverschlüsselte Sicherungen mit Passphrase brauchen sie noch.
let passInput = null;
if (backup.secrets === "Passphrase" && !backup.verschluesselt) {
passInput = erstelleElement("input", {
type: "password", klasse: "eingabe", placeholder: "Passphrase der Sicherung…", required: true
});
form.append(feld("Passphrase für Zugangsdaten", passInput));
}
let modal;
const restoreBtn = erstelleElement("button", {
type: "submit", klasse: "schaltflaeche warnung", text: "Jetzt wiederherstellen"
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const passphrase = passInput?.value?.trim() || null;
if (passInput && !passphrase) {
zeigeMeldung("Bitte gib die Passphrase an.", "warnung");
passInput.focus();
return;
}
restoreBtn.disabled = true;
restoreBtn.textContent = "Stelle wieder her…";
try {
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";
}
});
modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: "Abbrechen", onclick: () => modal.schliessen()
}),
restoreBtn
]);
modal.oeffnen();
}
/**
* 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 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",
text: "Löschen",
onclick: async () => {
loeschenBtn.disabled = true;
try {
const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(fileName)}`, {
method: "DELETE"
});
zeigeMeldung(res.meldung || "Sicherung gelöscht.", "erfolg");
modal.schliessen();
ladeSicherungen();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
loeschenBtn.disabled = false;
}
}
});
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]));
}