Compare commits
No commits in common. "main" and "v1.0.0-rc7" have entirely different histories.
main
...
v1.0.0-rc7
101
CHANGELOG.md
101
CHANGELOG.md
@ -1,106 +1,5 @@
|
||||
# Änderungen
|
||||
|
||||
## V1 — Release Candidate 9, 18. August 2026
|
||||
|
||||
### Sicherungsart je Auftrag
|
||||
|
||||
Bisher entschied die Anlage allein: Liegt ein Elternbackup vor, wird
|
||||
inkrementell gesichert. Jetzt wählbar —
|
||||
|
||||
- **inkrementell** (Standard, bisheriges Verhalten),
|
||||
- **immer voll**, oder
|
||||
- inkrementell **mit festem Volltag**, etwa „immer freitags".
|
||||
|
||||
Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
|
||||
Server in UTC, bekäme ein Betreiber in Berlin seine Vollsicherung am
|
||||
Donnerstagabend und wunderte sich, warum sie freitags fehlt.
|
||||
|
||||
**Der Platzbedarf steigt bei „immer voll" nicht nennenswert** — unveränderte
|
||||
Blöcke werden dedupliziert. Was steigt, ist die Laufzeit. Das steht so in der
|
||||
Maske, weil es die häufigste Verwechslung ist.
|
||||
|
||||
Migration 000014 mit drei CHECKs. Der dritte lehnt „immer voll" zusammen mit
|
||||
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Das Aufnahme-Token eines Agenten zeigte „undefined".** Das Feld heißt
|
||||
`token`, nicht `enrollment_token` — Letzteres ist der Name im *Anfrage*körper
|
||||
der Registrierung. Der dritte Formfehler dieser Art; alle konsumierten
|
||||
Endpunkte sind jetzt gegen den laufenden Dienst abgeglichen statt aus der
|
||||
Struktur abgeleitet.
|
||||
|
||||
### Aufnahmedialog mit Anleitung
|
||||
|
||||
Vollständige Anleitung für **Linux und Windows**, umschaltbar, mit fertig
|
||||
ausgefüllten Befehlen — Serveradresse und Token eingesetzt, jeder Schritt
|
||||
einzeln kopierbar. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
|
||||
jemand `<token>` wörtlich einsetzt.
|
||||
|
||||
Dazu die beiden Stolperstellen: `--state` erwartet eine **Datei**, und der
|
||||
Agent braucht Schreibzugriff auf das Repository.
|
||||
|
||||
### update.sh rüstet die Wiederherstellungsfläche nach
|
||||
|
||||
Sie kam mit rc8 dazu; eine Anlage aus einer älteren Fassung hat sie nicht. Ohne
|
||||
sie scheitert jede Wiederherstellung an `ProtectSystem=strict`. `update.sh`
|
||||
legt sie jetzt an und trägt sie in `ReadWritePaths` ein — ein Schritt, den man
|
||||
von Hand ausführen muss, wird übersehen und fällt erst im Ernstfall auf.
|
||||
|
||||
## V1 — Release Candidate 8, 18. August 2026
|
||||
|
||||
Wiederherstellung von Dateien und Ordnern mit Auswahl statt Textfeld — und die
|
||||
Erklärung, warum vorher gar keine Wiederherstellung funktionierte.
|
||||
|
||||
### Warum keine Wiederherstellung ging
|
||||
|
||||
Nicht die Rechte des Zielverzeichnisses, sondern die Härtung des Dienstes: Er
|
||||
läuft mit `ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
|
||||
Sicherungsordner. Jedes Ziel außerhalb endete mit `mkdir: permission denied` —
|
||||
und zwar **nach** der Vorabprüfung, an der unangenehmsten Stelle. `/tmp`
|
||||
scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein eigenes `/tmp`, und
|
||||
was dort landet, ist von außen unsichtbar.
|
||||
|
||||
`setup.sh` legt jetzt `/srv/syncova-restore` an und trägt es ein;
|
||||
`--wiederherstellungsziel` ergänzt weitere. Der Ort liegt unter `/srv`, weil
|
||||
`/var/lib` auf der Sperrliste des Zielschutzes steht — beide Regeln zugleich zu
|
||||
erfüllen lässt genau `/srv` übrig.
|
||||
|
||||
### Auswahl statt Textfeld
|
||||
|
||||
- **Ordnerbaum für das Ziel.** Er meldet je Verzeichnis, ob der Dienst dort
|
||||
schreiben darf — **gemessen** durch eine Probedatei, nicht aus den Rechtebits
|
||||
abgeleitet. Gesperrte Orte werden gezeigt, nicht versteckt: Sonst bliebe
|
||||
offen, warum ein Pfad fehlt.
|
||||
- **Browser für den Backup-Inhalt.** Ordner **und** einzelne Dateien lassen
|
||||
sich zurückholen. Der Baum entsteht aus den Pfaden, nicht aus
|
||||
Verzeichniseinträgen — ein Manifest kann eine Datei enthalten, deren
|
||||
Elternordner nicht als eigener Eintrag vorliegt.
|
||||
|
||||
Zwei neue Endpunkte, der eingefrorene Vertrag ist entsprechend erweitert:
|
||||
`GET /filesystem/browse` und `GET /backups/{id}/contents`.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **„can't access property toLocaleString, chunks_checked is undefined"** beim
|
||||
Integritätslauf. Die Ergebnisse liegen unter `details`, und die Felder heißen
|
||||
`missing_chunks`/`corrupted_chunks`. Betrifft alle vier Prüfendpunkte — sie
|
||||
tragen dieselbe Hülle. Derselbe Fehler wie zuvor bei `/retention-policies`:
|
||||
die Antwortform angenommen statt geprüft. Alle konsumierten Endpunkte sind
|
||||
jetzt gegen den laufenden Dienst abgeglichen.
|
||||
|
||||
### Geist Mono liegt im Paket
|
||||
|
||||
Drei Schnitte, 128 KB, OFL-Lizenz dabei. Ausgeliefert vom eigenen Ursprung —
|
||||
das verlangt die CSP, und ein Backup-Server, dessen Oberfläche von der
|
||||
Erreichbarkeit eines CDN abhängt, wäre auch ohne CSP falsch.
|
||||
|
||||
### Bekannte Grenze
|
||||
|
||||
**Eine Auswahl je Lauf**, kein Mehrfachhaken. Eine Liste ausgewählter Pfade
|
||||
kennt die API nicht; mehrere Läufe hintereinander ergäben mehrere Ausgänge, und
|
||||
ein „teilweise fehlgeschlagen" ließe sich dann nicht mehr erklären.
|
||||
|
||||
## V1 — Release Candidate 7, 18. August 2026
|
||||
|
||||
Behebt einen Absturz, macht die Sitzung brauchbar und stellt das Aussehen um.
|
||||
|
||||
@ -1,422 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/syncova/syncova/packages/platform/logging"
|
||||
"github.com/syncova/syncova/packages/recovery"
|
||||
"github.com/syncova/syncova/packages/repository"
|
||||
)
|
||||
|
||||
// browseHandler bedient die beiden Blätterendpunkte.
|
||||
//
|
||||
// Sie sind für eine Wiederherstellung gebaut und lösen zwei Probleme, die sich
|
||||
// mit einem Textfeld nicht lösen lassen:
|
||||
//
|
||||
// 1. **Wohin darf zurückgeschrieben werden?** Der Dienst läuft mit
|
||||
// `ProtectSystem=strict`; außerhalb weniger Pfade ist das Dateisystem für
|
||||
// ihn schreibgeschützt. Ein Betreiber tippt „/opt/test", bekommt
|
||||
// „permission denied" und hat keine Möglichkeit zu erkennen, welcher Ort
|
||||
// überhaupt in Frage kommt. `GET /filesystem/browse` beantwortet genau das
|
||||
// — es meldet je Verzeichnis, ob der **Dienst** dort schreiben kann,
|
||||
// geprüft durch einen tatsächlichen Schreibversuch.
|
||||
//
|
||||
// 2. **Was steckt in dem Backup?** Ohne Inhaltsverzeichnis lässt sich weder
|
||||
// eine einzelne Datei noch ein Unterordner gezielt zurückholen. `GET
|
||||
// /backups/{id}/contents` liefert das Manifest als Ebene eines Baums.
|
||||
//
|
||||
// Beide lesen nur.
|
||||
type browseHandler struct {
|
||||
restoreHandlerReference *restoreHandler
|
||||
targetGuard *recovery.TargetGuard
|
||||
}
|
||||
|
||||
// filesystemEntry ist ein Eintrag des Dateisystems.
|
||||
type filesystemEntry struct {
|
||||
// Name ist der letzte Pfadbestandteil.
|
||||
Name string `json:"name"`
|
||||
// Path ist der vollständige absolute Pfad.
|
||||
Path string `json:"path"`
|
||||
// IsDirectory unterscheidet Verzeichnis von Datei.
|
||||
IsDirectory bool `json:"is_directory"`
|
||||
// SizeBytes ist die Größe bei Dateien.
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
// IsWritable meldet, ob der Dienst hier anlegen darf.
|
||||
//
|
||||
// Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten:
|
||||
// Unter `ProtectSystem=strict` sagen die Bits nichts über das aus, was der
|
||||
// Namensraum zulässt.
|
||||
IsWritable bool `json:"is_writable"`
|
||||
// ForbiddenReason nennt den Grund, wenn der Zielschutz den Ort ausschließt.
|
||||
ForbiddenReason string `json:"forbidden_reason,omitempty"`
|
||||
}
|
||||
|
||||
// browseFilesystemResponse ist die Antwort auf das Blättern im Dateisystem.
|
||||
type browseFilesystemResponse struct {
|
||||
// Path ist das aufgelistete Verzeichnis.
|
||||
Path string `json:"path"`
|
||||
// ParentPath ist das übergeordnete Verzeichnis; leer bei der Wurzel.
|
||||
ParentPath string `json:"parent_path,omitempty"`
|
||||
// Entries sind die enthaltenen Verzeichnisse.
|
||||
Entries []filesystemEntry `json:"entries"`
|
||||
// SuggestedPaths sind Orte, an denen der Dienst nachweislich schreiben darf.
|
||||
//
|
||||
// Sie stehen in der Antwort, damit die Oberfläche einen brauchbaren
|
||||
// Startpunkt anbieten kann, statt den Betreiber suchen zu lassen.
|
||||
SuggestedPaths []string `json:"suggested_paths,omitempty"`
|
||||
}
|
||||
|
||||
// backupContentEntry ist ein Eintrag im Inhaltsverzeichnis eines Backups.
|
||||
type backupContentEntry struct {
|
||||
Name string `json:"name"`
|
||||
// Path ist der Pfad im Manifest — genau der Wert, den eine
|
||||
// Wiederherstellung als `path_prefix` erwartet.
|
||||
Path string `json:"path"`
|
||||
IsDirectory bool `json:"is_directory"`
|
||||
EntryType string `json:"entry_type"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
ModifiedAt string `json:"modified_at,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// ChildCount ist die Zahl der Einträge unterhalb eines Verzeichnisses.
|
||||
ChildCount int `json:"child_count,omitempty"`
|
||||
// TotalBytes ist die Datenmenge unterhalb eines Verzeichnisses.
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// browseBackupResponse ist die Antwort auf das Blättern im Backup.
|
||||
type browseBackupResponse struct {
|
||||
BackupID string `json:"backup_id"`
|
||||
Path string `json:"path"`
|
||||
ParentPath string `json:"parent_path,omitempty"`
|
||||
// Entries sind die Einträge auf dieser Ebene.
|
||||
Entries []backupContentEntry `json:"entries"`
|
||||
// TotalEntryCount ist die Zahl aller Einträge im Backup.
|
||||
TotalEntryCount int `json:"total_entry_count"`
|
||||
}
|
||||
|
||||
// handleBrowseFilesystem bedient GET /filesystem/browse.
|
||||
func (handler *browseHandler) handleBrowseFilesystem(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
|
||||
|
||||
requestedPath := strings.TrimSpace(request.URL.Query().Get("path"))
|
||||
if requestedPath == "" {
|
||||
requestedPath = "/"
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(requestedPath) {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewValidationError("Der Pfad muss absolut sein."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Symlinks werden aufgelöst, bevor gelesen wird: Sonst ließe sich über
|
||||
// einen Verweis an jeder Prüfung vorbei in ein fremdes Verzeichnis sehen.
|
||||
resolvedPath, resolveError := filepath.EvalSymlinks(filepath.Clean(requestedPath))
|
||||
if resolveError != nil {
|
||||
resolvedPath = filepath.Clean(requestedPath)
|
||||
}
|
||||
|
||||
directoryEntries, readError := os.ReadDir(resolvedPath)
|
||||
if readError != nil {
|
||||
if errors.Is(readError, os.ErrNotExist) {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewNotFoundError("Das Verzeichnis wurde nicht gefunden."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewValidationError("Das Verzeichnis lässt sich nicht lesen: "+readError.Error()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entries := make([]filesystemEntry, 0, len(directoryEntries))
|
||||
|
||||
for _, directoryEntry := range directoryEntries {
|
||||
// Nur Verzeichnisse: Ein Wiederherstellungsziel ist immer ein
|
||||
// Verzeichnis, und die Dateien daneben wären nur Rauschen.
|
||||
if !directoryEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Versteckte Verzeichnisse bleiben draußen. Wer eines braucht, tippt
|
||||
// den Pfad — die Liste soll den Normalfall zeigen.
|
||||
if strings.HasPrefix(directoryEntry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
childPath := filepath.Join(resolvedPath, directoryEntry.Name())
|
||||
|
||||
entry := filesystemEntry{
|
||||
Name: directoryEntry.Name(),
|
||||
Path: childPath,
|
||||
IsDirectory: true,
|
||||
IsWritable: directoryIsWritable(childPath),
|
||||
}
|
||||
|
||||
if handler.targetGuard != nil {
|
||||
if guardError := handler.targetGuard.Validate(childPath); guardError != nil {
|
||||
entry.ForbiddenReason = guardError.Error()
|
||||
// Ein gesperrter Ort ist nie ein zulässiges Ziel, auch wenn das
|
||||
// Dateisystem ihn zuließe.
|
||||
entry.IsWritable = false
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
|
||||
return entries[firstIndex].Name < entries[secondIndex].Name
|
||||
})
|
||||
|
||||
response := browseFilesystemResponse{
|
||||
Path: resolvedPath,
|
||||
Entries: entries,
|
||||
SuggestedPaths: writableSuggestions(handler.targetGuard),
|
||||
}
|
||||
|
||||
if resolvedPath != "/" {
|
||||
response.ParentPath = filepath.Dir(resolvedPath)
|
||||
}
|
||||
|
||||
WriteSuccess(responseWriter, request, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleBrowseBackupContents bedient GET /backups/{id}/contents.
|
||||
func (handler *browseHandler) handleBrowseBackupContents(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
|
||||
|
||||
backupIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||
if parseError != nil {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewValidationError("Die Backup-Kennung ist keine gültige UUID."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
repositoryPath, backupIDInRepository, resolveError := handler.restoreHandlerReference.resolveBackup(
|
||||
request.Context(), backupIdentifier)
|
||||
if resolveError != nil {
|
||||
WriteError(responseWriter, request, requestLogger, resolveError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Schreibgeschützt: Ein Inhaltsverzeichnis liest nur und soll neben einer
|
||||
// laufenden Sicherung erstellt werden können.
|
||||
openedRepository, openError := repository.Open(request.Context(), repositoryPath,
|
||||
repository.OpenOptions{ReadOnly: true}, handler.restoreHandlerReference.logger)
|
||||
if openError != nil {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewServiceUnavailableError("Das Repository des Backups ist derzeit nicht erreichbar."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = openedRepository.Close() }()
|
||||
|
||||
backupManifest, manifestError := openedRepository.ReadManifest(request.Context(), backupIDInRepository)
|
||||
if manifestError != nil {
|
||||
WriteError(responseWriter, request, requestLogger,
|
||||
NewValidationError("Das Manifest des Backups lässt sich nicht lesen: "+manifestError.Error()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
currentPath := strings.Trim(strings.TrimSpace(request.URL.Query().Get("path")), "/")
|
||||
|
||||
response := browseBackupResponse{
|
||||
BackupID: backupIdentifier.String(),
|
||||
Path: currentPath,
|
||||
Entries: collectLevel(backupManifest.Entries, currentPath),
|
||||
TotalEntryCount: len(backupManifest.Entries),
|
||||
}
|
||||
|
||||
if currentPath != "" {
|
||||
parentPath := path.Dir(currentPath)
|
||||
if parentPath == "." {
|
||||
parentPath = ""
|
||||
}
|
||||
|
||||
response.ParentPath = parentPath
|
||||
}
|
||||
|
||||
WriteSuccess(responseWriter, request, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// collectLevel bildet aus einem flachen Manifest eine Verzeichnisebene.
|
||||
//
|
||||
// Das Manifest kennt keine Baumstruktur, sondern eine flache Liste von Pfaden.
|
||||
// Der Baum entsteht hier — und zwar **ohne** sich auf Verzeichniseinträge zu
|
||||
// verlassen: Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis
|
||||
// nicht als eigener Eintrag vorliegt. Wer nur die Einträge vom Typ `directory`
|
||||
// auflistet, verliert dann ganze Teilbäume.
|
||||
func collectLevel(manifestEntries []repository.ManifestEntry, currentPath string) []backupContentEntry {
|
||||
prefix := ""
|
||||
if currentPath != "" {
|
||||
prefix = currentPath + "/"
|
||||
}
|
||||
|
||||
// Verzeichnisse werden über ihre Kinder erkannt und dabei gleich
|
||||
// aufsummiert: Ein Betreiber will vor dem Zurückholen wissen, wie viel an
|
||||
// einem Ordner hängt.
|
||||
directories := make(map[string]*backupContentEntry)
|
||||
files := make([]backupContentEntry, 0, 32)
|
||||
|
||||
for _, manifestEntry := range manifestEntries {
|
||||
entryPath := strings.Trim(manifestEntry.Path, "/")
|
||||
|
||||
if prefix != "" && !strings.HasPrefix(entryPath, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
remainder := strings.TrimPrefix(entryPath, prefix)
|
||||
if remainder == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
separatorIndex := strings.Index(remainder, "/")
|
||||
|
||||
if separatorIndex < 0 {
|
||||
// Direktes Kind dieser Ebene.
|
||||
if manifestEntry.EntryType == "directory" {
|
||||
directoryPath := prefix + remainder
|
||||
|
||||
if _, exists := directories[remainder]; !exists {
|
||||
directories[remainder] = &backupContentEntry{
|
||||
Name: remainder,
|
||||
Path: directoryPath,
|
||||
IsDirectory: true,
|
||||
EntryType: "directory",
|
||||
Mode: manifestEntry.Mode,
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, backupContentEntry{
|
||||
Name: remainder,
|
||||
Path: entryPath,
|
||||
IsDirectory: false,
|
||||
EntryType: manifestEntry.EntryType,
|
||||
SizeBytes: manifestEntry.SizeBytes,
|
||||
ModifiedAt: formatOptionalTime(manifestEntry),
|
||||
Mode: manifestEntry.Mode,
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Ein Nachfahre: Er belegt, dass es das Verzeichnis gibt, auch wenn
|
||||
// kein eigener Eintrag dafür existiert.
|
||||
directoryName := remainder[:separatorIndex]
|
||||
|
||||
existing, exists := directories[directoryName]
|
||||
if !exists {
|
||||
existing = &backupContentEntry{
|
||||
Name: directoryName,
|
||||
Path: prefix + directoryName,
|
||||
IsDirectory: true,
|
||||
EntryType: "directory",
|
||||
}
|
||||
directories[directoryName] = existing
|
||||
}
|
||||
|
||||
existing.ChildCount++
|
||||
existing.TotalBytes += manifestEntry.SizeBytes
|
||||
}
|
||||
|
||||
entries := make([]backupContentEntry, 0, len(directories)+len(files))
|
||||
|
||||
for _, directoryEntry := range directories {
|
||||
entries = append(entries, *directoryEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, files...)
|
||||
|
||||
// Verzeichnisse zuerst, dann alphabetisch — die Reihenfolge, die jeder
|
||||
// Dateimanager verwendet.
|
||||
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
|
||||
if entries[firstIndex].IsDirectory != entries[secondIndex].IsDirectory {
|
||||
return entries[firstIndex].IsDirectory
|
||||
}
|
||||
|
||||
return entries[firstIndex].Name < entries[secondIndex].Name
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// formatOptionalTime gibt einen Zeitstempel aus, sofern gesetzt.
|
||||
func formatOptionalTime(manifestEntry repository.ManifestEntry) string {
|
||||
if manifestEntry.ModifiedAt.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return manifestEntry.ModifiedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
// directoryIsWritable prüft durch einen echten Schreibversuch.
|
||||
//
|
||||
// Die Rechtebits zu lesen genügt nicht: Unter `ProtectSystem=strict` ist das
|
||||
// Dateisystem für den Dienst außerhalb weniger Pfade schreibgeschützt, und
|
||||
// davon steht nichts im Modus. Genau diese Lücke hat dazu geführt, dass eine
|
||||
// Wiederherstellung nach `/opt/test` mit „permission denied" endete, obwohl
|
||||
// das Verzeichnis dem Anschein nach beschreibbar war.
|
||||
func directoryIsWritable(directoryPath string) bool {
|
||||
probeFile, createError := os.CreateTemp(directoryPath, ".syncova-schreibprobe-*")
|
||||
if createError != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
probeName := probeFile.Name()
|
||||
|
||||
_ = probeFile.Close()
|
||||
_ = os.Remove(probeName)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// writableSuggestions nennt Orte, an denen der Dienst nachweislich schreiben darf.
|
||||
//
|
||||
// Ohne diese Liste sucht ein Betreiber im Blindflug: Die meisten Verzeichnisse
|
||||
// eines gehärteten Systems scheiden aus, und welche übrig bleiben, hängt an der
|
||||
// systemd-Einheit — nicht an etwas, das man dem Dateisystem ansieht.
|
||||
func writableSuggestions(targetGuard *recovery.TargetGuard) []string {
|
||||
// Reihenfolge ist Absicht: Zuerst die Flaeche, die `setup.sh` anlegt und
|
||||
// in ReadWritePaths eintraegt. `/var/lib` steht bewusst nicht dabei — es
|
||||
// ist im Zielschutz gesperrt.
|
||||
candidates := []string{
|
||||
"/srv/syncova-restore",
|
||||
"/srv",
|
||||
"/var/tmp",
|
||||
"/home",
|
||||
}
|
||||
|
||||
suggestions := make([]string, 0, len(candidates))
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if targetGuard != nil {
|
||||
if guardError := targetGuard.Validate(candidate); guardError != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if directoryIsWritable(candidate) {
|
||||
suggestions = append(suggestions, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/syncova/syncova/packages/repository"
|
||||
)
|
||||
|
||||
// TestCollectLevelBuildsTreeWithoutDirectoryEntries haelt fest, dass der Baum
|
||||
// aus den Pfaden entsteht und nicht aus Verzeichniseintraegen.
|
||||
//
|
||||
// Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis nicht als
|
||||
// eigener Eintrag vorliegt — etwa bei einer Quelle, die nur Dateien meldet. Wer
|
||||
// nur die Eintraege vom Typ "directory" auflistet, verliert dann ganze
|
||||
// Teilbaeume, und die Datei ist ueber die Oberflaeche nicht mehr erreichbar.
|
||||
func TestCollectLevelBuildsTreeWithoutDirectoryEntries(testInstance *testing.T) {
|
||||
manifestEntries := []repository.ManifestEntry{
|
||||
// Kein Eintrag fuer "berichte" selbst.
|
||||
{Path: "berichte/2026/jahr.pdf", EntryType: "file", SizeBytes: 900},
|
||||
{Path: "berichte/2025/jahr.pdf", EntryType: "file", SizeBytes: 100},
|
||||
{Path: "notiz.txt", EntryType: "file", SizeBytes: 6},
|
||||
}
|
||||
|
||||
rootLevel := collectLevel(manifestEntries, "")
|
||||
|
||||
if len(rootLevel) != 2 {
|
||||
testInstance.Fatalf("erwartet 2 Eintraege auf der Wurzel, erhalten %d", len(rootLevel))
|
||||
}
|
||||
|
||||
// Verzeichnisse stehen vorn.
|
||||
if !rootLevel[0].IsDirectory || rootLevel[0].Name != "berichte" {
|
||||
testInstance.Errorf("das Verzeichnis berichte fehlt oder steht nicht vorn: %+v", rootLevel[0])
|
||||
}
|
||||
|
||||
// Die Kennzahlen summieren den ganzen Teilbaum: Ein Betreiber will vor dem
|
||||
// Zurueckholen wissen, wie viel an einem Ordner haengt.
|
||||
if rootLevel[0].ChildCount != 2 || rootLevel[0].TotalBytes != 1000 {
|
||||
testInstance.Errorf("Kennzahlen des Ordners falsch: %d Objekte, %d Byte",
|
||||
rootLevel[0].ChildCount, rootLevel[0].TotalBytes)
|
||||
}
|
||||
|
||||
// Und eine Ebene tiefer erscheinen die Jahresordner.
|
||||
deeperLevel := collectLevel(manifestEntries, "berichte")
|
||||
|
||||
if len(deeperLevel) != 2 {
|
||||
testInstance.Fatalf("erwartet 2 Jahresordner, erhalten %d", len(deeperLevel))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectLevelRespectsDirectoryBoundary haelt die Verzeichnisgrenze fest.
|
||||
//
|
||||
// "dokumente" darf nicht auch "dokumentation" treffen — sonst holte eine
|
||||
// Wiederherstellung Daten zurueck, die niemand ausgewaehlt hat.
|
||||
func TestCollectLevelRespectsDirectoryBoundary(testInstance *testing.T) {
|
||||
manifestEntries := []repository.ManifestEntry{
|
||||
{Path: "dokumente/a.txt", EntryType: "file", SizeBytes: 1},
|
||||
{Path: "dokumentation/b.txt", EntryType: "file", SizeBytes: 1},
|
||||
}
|
||||
|
||||
level := collectLevel(manifestEntries, "dokumente")
|
||||
|
||||
if len(level) != 1 || level[0].Name != "a.txt" {
|
||||
testInstance.Errorf("die Verzeichnisgrenze wird nicht beachtet: %+v", level)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectLevelKeepsFileMetadata prueft die Angaben je Datei.
|
||||
func TestCollectLevelKeepsFileMetadata(testInstance *testing.T) {
|
||||
modificationTime := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
level := collectLevel([]repository.ManifestEntry{
|
||||
{Path: "notiz.txt", EntryType: "file", SizeBytes: 42, Mode: "0644", ModifiedAt: modificationTime},
|
||||
}, "")
|
||||
|
||||
if len(level) != 1 {
|
||||
testInstance.Fatalf("erwartet einen Eintrag, erhalten %d", len(level))
|
||||
}
|
||||
|
||||
// Der Pfad ist genau der Wert, den eine Wiederherstellung als
|
||||
// `path_prefix` erwartet — eine Abweichung faellt sonst erst beim
|
||||
// Zurueckschreiben auf.
|
||||
if level[0].Path != "notiz.txt" || level[0].SizeBytes != 42 || level[0].Mode != "0644" {
|
||||
testInstance.Errorf("Angaben der Datei unvollstaendig: %+v", level[0])
|
||||
}
|
||||
|
||||
if level[0].ModifiedAt != "2026-08-18T10:00:00Z" {
|
||||
testInstance.Errorf("Zeitstempel falsch: %q", level[0].ModifiedAt)
|
||||
}
|
||||
}
|
||||
@ -122,5 +122,3 @@ POST /api/v1/users users.write
|
||||
POST /api/v1/users/{id}/mfa/disable users.write
|
||||
POST /api/v1/verification verification.write
|
||||
POST /api/v1/verification/{id}/cancel verification.write
|
||||
GET /api/v1/filesystem/browse restores.read
|
||||
GET /api/v1/backups/{id}/contents restores.read
|
||||
|
||||
@ -89,14 +89,6 @@ type jobRequest struct {
|
||||
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
||||
// BackupMode ist "incremental" (Standard) oder "always_full".
|
||||
BackupMode string `json:"backup_mode,omitempty"`
|
||||
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
|
||||
//
|
||||
// 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger
|
||||
// Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt"
|
||||
// unterscheiden.
|
||||
FullBackupWeekday *int `json:"full_backup_weekday,omitempty"`
|
||||
// MaximumConcurrency begrenzt gleichzeitige Läufe.
|
||||
MaximumConcurrency int `json:"max_concurrency,omitempty"`
|
||||
}
|
||||
@ -135,14 +127,6 @@ type jobResponse struct {
|
||||
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
||||
// BackupMode ist "incremental" (Standard) oder "always_full".
|
||||
BackupMode string `json:"backup_mode,omitempty"`
|
||||
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
|
||||
//
|
||||
// 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger
|
||||
// Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt"
|
||||
// unterscheiden.
|
||||
FullBackupWeekday *int `json:"full_backup_weekday,omitempty"`
|
||||
// MaximumConcurrency begrenzt gleichzeitige Läufe.
|
||||
MaximumConcurrency int `json:"max_concurrency"`
|
||||
// NextRunAt ist der nächste Zeitpunkt in UTC.
|
||||
|
||||
@ -57,8 +57,6 @@ func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job,
|
||||
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
|
||||
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
|
||||
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
|
||||
BackupMode: jobs.BackupMode(jobPayload.BackupMode),
|
||||
FullBackupWeekday: weekdayFromPayload(jobPayload.FullBackupWeekday),
|
||||
MaximumConcurrency: maximumConcurrency,
|
||||
RetryPolicy: scheduler.DefaultRetryPolicy(),
|
||||
CreatedBy: &creatorID,
|
||||
@ -154,8 +152,6 @@ func buildJobResponse(sourceJob *jobs.Job) jobResponse {
|
||||
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
|
||||
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
|
||||
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
|
||||
BackupMode: string(sourceJob.BackupMode),
|
||||
FullBackupWeekday: weekdayToPayload(sourceJob.FullBackupWeekday),
|
||||
MaximumConcurrency: sourceJob.MaximumConcurrency,
|
||||
NextRunAt: sourceJob.NextRunAt,
|
||||
LastRunAt: sourceJob.LastRunAt,
|
||||
@ -194,33 +190,3 @@ func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest {
|
||||
|
||||
return scheduleData
|
||||
}
|
||||
|
||||
// weekdayFromPayload uebersetzt einen Wochentag aus der Anfrage.
|
||||
//
|
||||
// Ein Wert ausserhalb von 0..6 wird verworfen statt gekappt: Ein
|
||||
// stillschweigend auf Sonntag gesetzter Montag waere ein Fehler, den niemand
|
||||
// bemerkt — die Vollsicherung liefe dann am falschen Tag.
|
||||
func weekdayFromPayload(requestedWeekday *int) *time.Weekday {
|
||||
if requestedWeekday == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if *requestedWeekday < 0 || *requestedWeekday > 6 {
|
||||
return nil
|
||||
}
|
||||
|
||||
convertedWeekday := time.Weekday(*requestedWeekday)
|
||||
|
||||
return &convertedWeekday
|
||||
}
|
||||
|
||||
// weekdayToPayload uebersetzt einen Wochentag fuer die Antwort.
|
||||
func weekdayToPayload(storedWeekday *time.Weekday) *int {
|
||||
if storedWeekday == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
convertedValue := int(*storedWeekday)
|
||||
|
||||
return &convertedValue
|
||||
}
|
||||
|
||||
@ -377,19 +377,6 @@ func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies
|
||||
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
|
||||
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
|
||||
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
|
||||
|
||||
// Blättern in Dateisystem und Backup.
|
||||
//
|
||||
// Beide gehören zur Wiederherstellung und tragen deshalb deren Leserecht:
|
||||
// Wer eine Wiederherstellung vorbereiten darf, muss sehen können, was im
|
||||
// Backup steckt und wohin sich zurückschreiben lässt.
|
||||
browseHandlerInstance := &browseHandler{
|
||||
restoreHandlerReference: restoreHandlerInstance,
|
||||
targetGuard: restoreHandlerInstance.targetGuard,
|
||||
}
|
||||
|
||||
requestMultiplexer.Handle("GET "+apiBasePath+"/filesystem/browse", protected("restores.read", browseHandlerInstance.handleBrowseFilesystem))
|
||||
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/contents", protected("restores.read", browseHandlerInstance.handleBrowseBackupContents))
|
||||
}
|
||||
|
||||
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,92 +0,0 @@
|
||||
Copyright (c) 2023 Vercel, in collaboration with basement.studio
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@ -10,7 +10,7 @@
|
||||
* könnte er sich als ein anderes System ausgeben.
|
||||
*/
|
||||
|
||||
import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } from 'lucide-react';
|
||||
import { Ban, Copy, KeyRound, Plus, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useApiResource } from '@/api/useApiResource';
|
||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
||||
@ -32,7 +32,7 @@ import {
|
||||
useToast,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import {
|
||||
createEnrollmentToken,
|
||||
listAgents,
|
||||
@ -300,17 +300,6 @@ export function AgentsPage({
|
||||
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
||||
* Schaltfläche, und sie sagt, was sie bewirkt.
|
||||
*/
|
||||
/**
|
||||
* Zeigt das Aufnahme-Token einmalig — samt Anleitung für beide Systeme.
|
||||
*
|
||||
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
||||
* Schaltfläche, und sie sagt, was sie bewirkt.
|
||||
*
|
||||
* Die Befehle stehen **fertig ausgefüllt** da, mit Serveradresse und Token
|
||||
* eingesetzt. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
|
||||
* jemand `<token>` wörtlich einsetzt — und dann eine Fehlermeldung sucht, die
|
||||
* nichts mit seinem Problem zu tun hat.
|
||||
*/
|
||||
function IssuedTokenDialog({
|
||||
token,
|
||||
onClose,
|
||||
@ -319,199 +308,64 @@ function IssuedTokenDialog({
|
||||
readonly onClose: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
// Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der
|
||||
// der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx.
|
||||
const serverAddress = window.location.origin;
|
||||
|
||||
const copyText = async (textToCopy: string, entryKey: string) => {
|
||||
const copyToken = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopiedKey(entryKey);
|
||||
window.setTimeout(() => setCopiedKey(null), 2000);
|
||||
await navigator.clipboard.writeText(token.enrollment_token);
|
||||
setHasCopied(true);
|
||||
window.setTimeout(() => setHasCopied(false), 2000);
|
||||
} catch {
|
||||
toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.');
|
||||
toast.showInfo(
|
||||
'Kopieren nicht möglich',
|
||||
'Markieren Sie das Token und kopieren Sie es von Hand.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const linuxSteps = [
|
||||
{
|
||||
key: 'linux-paket',
|
||||
title: '1. Paket auspacken',
|
||||
command: `sudo mkdir -p /opt/syncova-agent
|
||||
sudo tar -xzf syncova-*-linux-amd64.tar.gz -C /tmp
|
||||
sudo cp /tmp/syncova-*/bin/syncova-agent /opt/syncova-agent/`,
|
||||
},
|
||||
{
|
||||
key: 'linux-konto',
|
||||
title: '2. Dienstkonto und Verzeichnisse',
|
||||
command: `sudo useradd --system --no-create-home --shell /usr/sbin/nologin syncova-agent
|
||||
sudo install -d -o syncova-agent -g syncova-agent /var/lib/syncova-agent`,
|
||||
},
|
||||
{
|
||||
key: 'linux-enroll',
|
||||
title: '3. Aufnehmen',
|
||||
command: `sudo -u syncova-agent /opt/syncova-agent/syncova-agent enroll \\
|
||||
--server ${serverAddress} \\
|
||||
--token ${token.token} \\
|
||||
--state /var/lib/syncova-agent/state.json`,
|
||||
},
|
||||
{
|
||||
key: 'linux-dienst',
|
||||
title: '4. Als Dienst einrichten',
|
||||
command: `sudo tee /etc/systemd/system/syncova-agent.service >/dev/null <<'EOF'
|
||||
[Unit]
|
||||
Description=Syncova Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=syncova-agent
|
||||
ExecStart=/opt/syncova-agent/syncova-agent run --state /var/lib/syncova-agent/state.json
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/syncova-agent
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now syncova-agent`,
|
||||
},
|
||||
];
|
||||
|
||||
const windowsSteps = [
|
||||
{
|
||||
key: 'win-paket',
|
||||
title: '1. Paket auspacken',
|
||||
command: `New-Item -ItemType Directory -Force "C:\\Program Files\\Syncova Agent"
|
||||
Expand-Archive syncova-*-windows-amd64.zip -DestinationPath $env:TEMP\\syncova
|
||||
Copy-Item $env:TEMP\\syncova\\*\\bin\\syncova-agent.exe "C:\\Program Files\\Syncova Agent\\"`,
|
||||
},
|
||||
{
|
||||
key: 'win-enroll',
|
||||
title: '2. Aufnehmen',
|
||||
command: `New-Item -ItemType Directory -Force "C:\\ProgramData\\Syncova"
|
||||
& "C:\\Program Files\\Syncova Agent\\syncova-agent.exe" enroll \`
|
||||
--server ${serverAddress} \`
|
||||
--token ${token.token} \`
|
||||
--state "C:\\ProgramData\\Syncova\\state.json"`,
|
||||
},
|
||||
{
|
||||
key: 'win-dienst',
|
||||
title: '3. Als Dienst einrichten',
|
||||
command: `New-Service -Name SyncovaAgent \`
|
||||
-DisplayName "Syncova Agent" \`
|
||||
-BinaryPathName '"C:\\Program Files\\Syncova Agent\\syncova-agent.exe" run --state "C:\\ProgramData\\Syncova\\state.json"' \`
|
||||
-StartupType Automatic
|
||||
Start-Service SyncovaAgent`,
|
||||
},
|
||||
];
|
||||
|
||||
const activeSteps = platform === 'linux' ? linuxSteps : windowsSteps;
|
||||
|
||||
return (
|
||||
<DialogRoot open onOpenChange={() => undefined}>
|
||||
<DialogContent size="xl">
|
||||
<DialogContent size="lg">
|
||||
<DialogHeader
|
||||
title="Agent aufnehmen"
|
||||
description={`Für „${token.agent_name}"`}
|
||||
title="Aufnahme-Token"
|
||||
description={`Für den Agenten „${token.agent_name}“`}
|
||||
/>
|
||||
<DialogBody>
|
||||
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
|
||||
Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst,
|
||||
wenn der Agent aufgenommen ist.
|
||||
Es wird nur als Hash gespeichert und lässt sich nicht wieder
|
||||
abrufen. Schließen Sie dieses Fenster erst, wenn Sie es sicher
|
||||
hinterlegt haben — sonst müssen Sie ein neues erzeugen.
|
||||
</Callout>
|
||||
|
||||
<div className="border border-line bg-sunken p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<code className="min-w-0 break-all font-mono text-sm text-fg">
|
||||
{token.token}
|
||||
<div className="rounded-md border border-line bg-sunken p-3">
|
||||
<code className="block break-all font-mono text-sm text-fg">
|
||||
{token.enrollment_token}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => void copyText(token.token, 'token')}
|
||||
>
|
||||
{copiedKey === 'token' ? <KeyRound /> : <Copy />}
|
||||
{copiedKey === 'token' ? 'Kopiert' : 'Kopieren'}
|
||||
</Button>
|
||||
</div>
|
||||
{token.expires_at ? (
|
||||
<p className="mt-2 text-xs text-fg-muted">
|
||||
Gültig bis {formatDateTime(token.expires_at)} — danach ein neues erzeugen.
|
||||
|
||||
<Button onClick={() => void copyToken()}>
|
||||
{hasCopied ? <KeyRound /> : <Copy />}
|
||||
{hasCopied ? 'Kopiert' : 'Token kopieren'}
|
||||
</Button>
|
||||
|
||||
<div className="border-t border-line pt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||
Auf dem zu sichernden System
|
||||
</p>
|
||||
<code className="mt-1.5 block overflow-x-auto rounded bg-sunken px-2 py-2 font-mono text-xs text-fg">
|
||||
syncova-agent enroll --server https://<dieser-server> --token
|
||||
<token> --state /var/lib/syncova-agent/state.json
|
||||
</code>
|
||||
<p className="mt-1.5 text-xs text-fg-muted">
|
||||
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
|
||||
Mit einem Verzeichnis hält sich der Agent für registriert und
|
||||
läuft ohne Token.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Systemwahl */}
|
||||
<div className="flex gap-2 border-b border-line pb-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={platform === 'linux' ? 'primary' : 'ghost'}
|
||||
onClick={() => setPlatform('linux')}
|
||||
>
|
||||
<Terminal />
|
||||
Linux
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={platform === 'windows' ? 'primary' : 'ghost'}
|
||||
onClick={() => setPlatform('windows')}
|
||||
>
|
||||
<MonitorCog />
|
||||
Windows (PowerShell)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{platform === 'windows' ? (
|
||||
<Callout tone="warning">
|
||||
Der Windows-Dienst ist gebaut und übersetzt, aber{' '}
|
||||
<strong>nie auf echter Hardware gefahren</strong>. Der
|
||||
Kommandozeilenweg ist nachgewiesen.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
{activeSteps.map((step) => (
|
||||
<div key={step.key}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-fg">{step.title}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void copyText(step.command, step.key)}
|
||||
>
|
||||
{copiedKey === step.key ? <KeyRound /> : <Copy />}
|
||||
{copiedKey === step.key ? 'Kopiert' : 'Kopieren'}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto border border-line bg-sunken p-2.5 font-mono text-xs text-fg">
|
||||
{step.command}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Callout tone="info" title="Zwei Stolperstellen">
|
||||
<ul className="mt-1 space-y-1 text-xs">
|
||||
<li>
|
||||
<code className="font-mono">--state</code> erwartet eine{' '}
|
||||
<strong>Datei</strong>, kein Verzeichnis. Mit einem Verzeichnis
|
||||
hält sich der Agent für registriert und läuft ohne Token.
|
||||
</li>
|
||||
<li>
|
||||
Der Agent braucht <strong>Schreibzugriff auf das Repository</strong>.
|
||||
Auf einem gemeinsamen Server ist das der lokale Pfad, bei
|
||||
getrennten Maschinen eine Freigabe.
|
||||
</li>
|
||||
</ul>
|
||||
</Callout>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Der Agent ist aufgenommen
|
||||
Ich habe das Token hinterlegt
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -197,22 +197,15 @@ export interface Agent {
|
||||
enrolled_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antwort auf die Erzeugung eines Aufnahme-Tokens.
|
||||
*
|
||||
* Das Feld heißt **`token`**, nicht `enrollment_token` — Letzteres ist der Name
|
||||
* im *Anfrage*körper der Registrierung. Die Verwechslung ließ die Oberfläche
|
||||
* „undefined" anzeigen, und der Betreiber hatte kein Token, obwohl der Server
|
||||
* eines ausgestellt hatte.
|
||||
*/
|
||||
/** Antwort auf die Erzeugung eines Aufnahme-Tokens. */
|
||||
export interface EnrollmentToken {
|
||||
id: string;
|
||||
/**
|
||||
* Das Token im Klartext — **einmalig**.
|
||||
*
|
||||
* Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen.
|
||||
* Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen. Die
|
||||
* Oberfläche muss das sagen, sonst schließt jemand das Fenster.
|
||||
*/
|
||||
token: string;
|
||||
enrollment_token: string;
|
||||
agent_name: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { createJob, listRepositories, WEEKDAY_LABELS } from './jobsApi';
|
||||
import { createJob, listRepositories } from './jobsApi';
|
||||
import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
|
||||
import {
|
||||
buildCreateRequest,
|
||||
@ -516,71 +516,6 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
|
||||
)}
|
||||
|
||||
<p className="mt-2 rounded-md border border-line bg-sunken px-3 py-2 text-sm text-fg">Ergibt: {describeDraftSchedule(jobDraft)}</p>
|
||||
|
||||
{/* --- Sicherungsart ---
|
||||
Sie gehört zum Zeitplan, nicht zur Quelle: Beides zusammen
|
||||
beantwortet die Frage „was passiert wann". */}
|
||||
<div className="mt-4 space-y-3 border-t border-line pt-4">
|
||||
<p className="text-sm font-medium text-fg">Sicherungsart</p>
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-2.5 border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
|
||||
<input
|
||||
type="radio"
|
||||
name="backup-mode"
|
||||
className="mt-0.5"
|
||||
checked={jobDraft.backupMode === 'incremental'}
|
||||
onChange={() => updateDraft({ backupMode: 'incremental' })}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-fg">Inkrementell</span>
|
||||
<span className="block text-xs text-fg-muted">
|
||||
Erster Lauf voll, danach nur Geändertes. Empfohlen.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-2.5 border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
|
||||
<input
|
||||
type="radio"
|
||||
name="backup-mode"
|
||||
className="mt-0.5"
|
||||
checked={jobDraft.backupMode === 'always_full'}
|
||||
onChange={() => updateDraft({ backupMode: 'always_full', fullBackupWeekday: -1 })}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-fg">Immer voll</span>
|
||||
<span className="block text-xs text-fg-muted">
|
||||
Jeder Lauf liest die gesamte Quelle. Kostet Laufzeit, kaum Platz
|
||||
— unveränderte Blöcke werden dedupliziert.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{jobDraft.backupMode === 'incremental' ? (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="block text-sm font-medium text-fg">
|
||||
Zusätzlich voll sichern an
|
||||
</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg"
|
||||
value={String(jobDraft.fullBackupWeekday)}
|
||||
onChange={(changeEvent) =>
|
||||
updateDraft({ fullBackupWeekday: Number(changeEvent.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="-1">Keinem festen Tag</option>
|
||||
{WEEKDAY_LABELS.map((weekdayLabel, weekdayIndex) => (
|
||||
<option key={weekdayLabel} value={String(weekdayIndex)}>
|
||||
{weekdayLabel}s
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-fg-muted">
|
||||
Gerechnet in der Zeitzone des Zeitplans.
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,7 +45,6 @@ import {
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
runJob,
|
||||
WEEKDAY_LABELS,
|
||||
type BackupJobRun,
|
||||
} from './jobsApi';
|
||||
|
||||
@ -277,15 +276,6 @@ export function JobDetailPage({
|
||||
: 'Ohne Grenze'}
|
||||
</DetailItem>
|
||||
<DetailItem label="Quellen">{job.sources.length}</DetailItem>
|
||||
<DetailItem label="Sicherungsart">
|
||||
{job.backup_mode === 'always_full' ? (
|
||||
'Immer voll'
|
||||
) : job.full_backup_weekday !== undefined ? (
|
||||
<>Inkrementell, {WEEKDAY_LABELS[job.full_backup_weekday]}s voll</>
|
||||
) : (
|
||||
'Inkrementell'
|
||||
)}
|
||||
</DetailItem>
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 border-t border-line pt-4">
|
||||
|
||||
@ -47,7 +47,6 @@ import {
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
runJob,
|
||||
WEEKDAY_LABELS,
|
||||
type BackupJob,
|
||||
} from './jobsApi';
|
||||
|
||||
@ -134,14 +133,7 @@ export function JobsPage({
|
||||
render: (job) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-fg">{job.name}</p>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{job.schedule_description}
|
||||
{job.backup_mode === 'always_full'
|
||||
? ' · immer voll'
|
||||
: job.full_backup_weekday !== undefined
|
||||
? ` · ${WEEKDAY_LABELS[job.full_backup_weekday]}s voll`
|
||||
: ''}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg-muted">{job.schedule_description}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@ -82,35 +82,8 @@ export interface CreateJobRequest {
|
||||
rto_seconds?: number;
|
||||
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||
bandwidth_limit_bps?: number;
|
||||
/**
|
||||
* Sicherungsart: `incremental` (Standard) oder `always_full`.
|
||||
*
|
||||
* Der Platzbedarf steigt bei `always_full` **nicht** nennenswert —
|
||||
* unveränderte Blöcke werden dedupliziert. Was steigt, ist die Laufzeit.
|
||||
*/
|
||||
backup_mode?: BackupMode;
|
||||
/**
|
||||
* Wochentag einer erzwungenen Vollsicherung.
|
||||
*
|
||||
* 0 = Sonntag … 6 = Samstag. Gerechnet in der Zeitzone des Zeitplans.
|
||||
*/
|
||||
full_backup_weekday?: number;
|
||||
}
|
||||
|
||||
/** Sicherungsart eines Auftrags. */
|
||||
export type BackupMode = 'incremental' | 'always_full';
|
||||
|
||||
/** Wochentage in der Zählung der API (0 = Sonntag). */
|
||||
export const WEEKDAY_LABELS: readonly string[] = [
|
||||
'Sonntag',
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
];
|
||||
|
||||
/** Auftrag in der Antwort der API. */
|
||||
export interface BackupJob {
|
||||
/** Öffentlicher Bezeichner. */
|
||||
@ -139,10 +112,6 @@ export interface BackupJob {
|
||||
last_outcome?: string;
|
||||
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||
bandwidth_limit_bps?: number;
|
||||
/** Sicherungsart. */
|
||||
backup_mode?: BackupMode;
|
||||
/** Wochentag einer erzwungenen Vollsicherung. */
|
||||
full_backup_weekday?: number;
|
||||
}
|
||||
|
||||
/** Sicherungsziel in der Antwort der API. */
|
||||
|
||||
Binary file not shown.
@ -36,7 +36,7 @@ import {
|
||||
useToast,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
||||
import { formatBytes, formatDateTime, formatDuration } from '@/lib/utils';
|
||||
import {
|
||||
adoptRepository,
|
||||
checkRepositoryHealth,
|
||||
@ -44,7 +44,7 @@ import {
|
||||
measureEnforcement,
|
||||
rebuildCatalog,
|
||||
startIntegrityScan,
|
||||
type IntegrityScanDetails,
|
||||
type IntegrityScanResult,
|
||||
type Repository,
|
||||
} from './repositoriesApi';
|
||||
|
||||
@ -56,7 +56,7 @@ export function RepositoriesPage({
|
||||
const toast = useToast();
|
||||
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
|
||||
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null);
|
||||
const [scanResult, setScanResult] = useState<IntegrityScanDetails | null>(null);
|
||||
const [scanResult, setScanResult] = useState<IntegrityScanResult | null>(null);
|
||||
const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null);
|
||||
|
||||
const [adoptName, setAdoptName] = useState('');
|
||||
@ -96,10 +96,10 @@ export function RepositoriesPage({
|
||||
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
|
||||
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
|
||||
// wird bald nicht mehr ernst genommen.
|
||||
if (result.missing_chunks > 0 || result.corrupted_chunks > 0) {
|
||||
if (result.chunks_missing > 0 || result.chunks_corrupted > 0) {
|
||||
toast.showError(
|
||||
'Der Integritätslauf hat Befunde',
|
||||
`${result.missing_chunks} Blöcke fehlen, ${result.corrupted_chunks} sind beschädigt.`,
|
||||
`${result.chunks_missing} Blöcke fehlen, ${result.chunks_corrupted} sind beschädigt.`,
|
||||
);
|
||||
} else {
|
||||
toast.showSuccess(
|
||||
@ -119,21 +119,11 @@ export function RepositoriesPage({
|
||||
});
|
||||
|
||||
const healthMutation = useMutation(checkRepositoryHealth, {
|
||||
onSuccess: (checkResponse) => {
|
||||
// Erreichbarkeit und Befund sind zwei Aussagen. Die erste steht in der
|
||||
// Hülle, die zweite in `details`.
|
||||
if (!checkResponse.reachable) {
|
||||
toast.showError(
|
||||
'Das Repository ist nicht erreichbar',
|
||||
checkResponse.error ?? 'Ohne nähere Angabe.',
|
||||
);
|
||||
} else {
|
||||
onSuccess: (health) => {
|
||||
toast.showSuccess(
|
||||
'Gesundheitsprüfung abgeschlossen',
|
||||
checkResponse.details?.message ?? 'Das Repository ist erreichbar.',
|
||||
health.message ?? `Zustand: ${health.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
repositoriesResource.reload();
|
||||
},
|
||||
onError: (apiError) =>
|
||||
@ -153,10 +143,11 @@ export function RepositoriesPage({
|
||||
});
|
||||
|
||||
const rebuildMutation = useMutation(rebuildCatalog, {
|
||||
onSuccess: (checkResponse) => {
|
||||
onSuccess: (result) => {
|
||||
toast.showSuccess(
|
||||
'Katalog neu aufgebaut',
|
||||
`${checkResponse.details?.backups_in_catalog ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
|
||||
result.summary ??
|
||||
`${result.backups_found ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
|
||||
);
|
||||
repositoriesResource.reload();
|
||||
},
|
||||
@ -419,10 +410,10 @@ function IntegrityScanCard({
|
||||
result,
|
||||
onClose,
|
||||
}: {
|
||||
readonly result: IntegrityScanDetails;
|
||||
readonly result: IntegrityScanResult;
|
||||
readonly onClose: () => void;
|
||||
}) {
|
||||
const hasFindings = result.missing_chunks > 0 || result.corrupted_chunks > 0;
|
||||
const hasFindings = result.chunks_missing > 0 || result.chunks_corrupted > 0;
|
||||
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
@ -438,7 +429,7 @@ function IntegrityScanCard({
|
||||
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
|
||||
{hasFindings
|
||||
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
|
||||
: result.summary}
|
||||
: 'Ohne Befund. Jeder geprüfte Block stimmt mit seiner Prüfsumme überein.'}
|
||||
</Callout>
|
||||
|
||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@ -446,36 +437,27 @@ function IntegrityScanCard({
|
||||
{result.chunks_checked.toLocaleString('de-DE')}
|
||||
</DetailItem>
|
||||
<DetailItem label="Fehlend">
|
||||
<span className={result.missing_chunks > 0 ? 'text-critical' : undefined}>
|
||||
{result.missing_chunks.toLocaleString('de-DE')}
|
||||
<span className={result.chunks_missing > 0 ? 'text-critical' : undefined}>
|
||||
{result.chunks_missing.toLocaleString('de-DE')}
|
||||
</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Beschädigt">
|
||||
<span className={result.corrupted_chunks > 0 ? 'text-critical' : undefined}>
|
||||
{result.corrupted_chunks.toLocaleString('de-DE')}
|
||||
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
|
||||
{result.chunks_corrupted.toLocaleString('de-DE')}
|
||||
</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Backups">
|
||||
{result.backups_healthy} von {result.backups_checked} vollständig
|
||||
</DetailItem>
|
||||
<DetailItem label="Dauer">{formatDuration(result.duration_seconds)}</DetailItem>
|
||||
</dl>
|
||||
|
||||
{!result.verified_chunk_contents ? (
|
||||
<Callout tone="warning" className="mt-4">
|
||||
Nur die Kennungen wurden geprüft, nicht die Blockinhalte. Das ist
|
||||
ein halber Nachweis.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
{result.affected_backup_ids && result.affected_backup_ids.length > 0 ? (
|
||||
{result.findings && result.findings.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||
Betroffene Backups
|
||||
Betroffene Objekte
|
||||
</p>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{result.affected_backup_ids.map((backupIdentifier) => (
|
||||
<li key={backupIdentifier} className="break-all font-mono text-xs text-fg-muted">
|
||||
{backupIdentifier}
|
||||
{result.findings.map((finding) => (
|
||||
<li key={finding} className="break-all font-mono text-xs text-fg-muted">
|
||||
{finding}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -32,49 +32,21 @@ export interface Repository {
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antworthülle der Repository-Prüfungen.
|
||||
*
|
||||
* **Alle vier Prüfendpunkte antworten in dieser Form** — Integritätslauf,
|
||||
* Gesundheitsprüfung, Verbindungstest und Katalog-Neuaufbau. Das eigentliche
|
||||
* Ergebnis steckt unter `details`, nicht an der Oberfläche der Antwort.
|
||||
*
|
||||
* Das falsch anzunehmen kostete eine Fehlermeldung, die wie ein Defekt der
|
||||
* Anlage aussah: „can't access property toLocaleString, chunks_checked is
|
||||
* undefined — das ist ein Problem der Prüfung, kein Befund am Repository."
|
||||
* Genau das Gegenteil war der Fall; die Prüfung war einwandfrei gelaufen.
|
||||
*/
|
||||
export interface RepositoryCheckResponse<TDetails> {
|
||||
repository_id: string;
|
||||
reachable: boolean;
|
||||
repository_uuid?: string;
|
||||
error?: string;
|
||||
details?: TDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergebnis eines Integritätslaufs.
|
||||
*
|
||||
* Die Feldnamen stammen aus `repository.ScanReport` und heißen anders herum als
|
||||
* erwartet: `missing_chunks`, nicht `chunks_missing`.
|
||||
*/
|
||||
export interface IntegrityScanDetails {
|
||||
/** Geprüfte Blöcke. */
|
||||
/** Ergebnis eines Integritätslaufs. */
|
||||
export interface IntegrityScanResult {
|
||||
/** Geprüft. */
|
||||
chunks_checked: number;
|
||||
/** Nicht auffindbar — jeder einzelne verhindert eine Wiederherstellung. */
|
||||
missing_chunks: number;
|
||||
/** Nicht auffindbar. */
|
||||
chunks_missing: number;
|
||||
/** Prüfsumme stimmt nicht. */
|
||||
corrupted_chunks: number;
|
||||
/** Blöcke ohne Verweis aus einem Manifest. */
|
||||
orphaned_chunks: number;
|
||||
backups_checked: number;
|
||||
backups_healthy: number;
|
||||
/** Meldet, ob die Blockinhalte gelesen wurden oder nur die Kennungen. */
|
||||
verified_chunk_contents: boolean;
|
||||
healthy: boolean;
|
||||
summary: string;
|
||||
/** Betroffene Backups, sofern benennbar. */
|
||||
affected_backup_ids?: string[] | null;
|
||||
chunks_corrupted: number;
|
||||
manifests_checked?: number;
|
||||
manifests_invalid?: number;
|
||||
duration_seconds?: number;
|
||||
/** Zusammenfassung im Klartext. */
|
||||
summary?: string;
|
||||
/** Betroffene Objekte, sofern benennbar. */
|
||||
findings?: string[];
|
||||
}
|
||||
|
||||
/** Ergebnis einer Gesundheitsprüfung. */
|
||||
@ -154,8 +126,8 @@ export async function updateRepository(
|
||||
/** Prüft die Erreichbarkeit. */
|
||||
export async function testRepository(
|
||||
repositoryIdentifier: string,
|
||||
): Promise<RepositoryCheckResponse<Record<string, unknown>>> {
|
||||
return requestApi<RepositoryCheckResponse<Record<string, unknown>>>(
|
||||
): Promise<RepositoryHealth> {
|
||||
return requestApi<RepositoryHealth>(
|
||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
@ -164,8 +136,8 @@ export async function testRepository(
|
||||
/** Führt eine Gesundheitsprüfung aus. */
|
||||
export async function checkRepositoryHealth(
|
||||
repositoryIdentifier: string,
|
||||
): Promise<RepositoryCheckResponse<RepositoryHealth>> {
|
||||
return requestApi<RepositoryCheckResponse<RepositoryHealth>>(
|
||||
): Promise<RepositoryHealth> {
|
||||
return requestApi<RepositoryHealth>(
|
||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
@ -180,21 +152,11 @@ export async function checkRepositoryHealth(
|
||||
*/
|
||||
export async function startIntegrityScan(
|
||||
repositoryIdentifier: string,
|
||||
): Promise<IntegrityScanDetails> {
|
||||
const response = await requestApi<RepositoryCheckResponse<IntegrityScanDetails>>(
|
||||
): Promise<IntegrityScanResult> {
|
||||
return requestApi<IntegrityScanResult>(
|
||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
|
||||
{ method: 'POST', idempotencyKey: true },
|
||||
);
|
||||
|
||||
// Ein nicht erreichbares Repository ist kein Befund am Bestand, sondern ein
|
||||
// Fehler des Laufs — die Unterscheidung, die diese Seite durchgehend macht.
|
||||
if (!response.reachable || !response.details) {
|
||||
throw new Error(
|
||||
response.error || 'Das Repository war während der Prüfung nicht erreichbar.',
|
||||
);
|
||||
}
|
||||
|
||||
return response.details;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -206,8 +168,8 @@ export async function startIntegrityScan(
|
||||
*/
|
||||
export async function rebuildCatalog(
|
||||
repositoryIdentifier: string,
|
||||
): Promise<RepositoryCheckResponse<{ backups_in_catalog?: number }>> {
|
||||
return requestApi<RepositoryCheckResponse<{ backups_in_catalog?: number }>>(
|
||||
): Promise<{ backups_found?: number; summary?: string }> {
|
||||
return requestApi<{ backups_found?: number; summary?: string }>(
|
||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
|
||||
{ method: 'POST', idempotencyKey: true },
|
||||
);
|
||||
|
||||
@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Auswahl dessen, was zurückgeholt werden soll.
|
||||
*
|
||||
* Bisher gab es nur „alles" — ein Textfeld für einen Teilbaum, das voraussetzte,
|
||||
* dass man die Pfade im Backup auswendig kennt. Jetzt lässt sich blättern.
|
||||
*
|
||||
* Der gewählte Pfad wandert als `path_prefix` in die Anfrage. Das genügt für
|
||||
* beides: Der Server vergleicht auf Gleichheit **oder** Präfix mit
|
||||
* Verzeichnisgrenze, trifft also sowohl einen ganzen Ordner als auch eine
|
||||
* einzelne Datei. „dokumente" trifft dabei nicht „dokumentation".
|
||||
*
|
||||
* Bewusste Grenze: **eine** Auswahl je Lauf, kein Mehrfachhaken. Eine Liste
|
||||
* ausgewählter Pfade kennt die API nicht, und sie vorzutäuschen — etwa durch
|
||||
* mehrere Läufe hintereinander — ergäbe mehrere Wiederherstellungen mit
|
||||
* getrenntem Ausgang. Ein „teilweise fehlgeschlagen" ließe sich dann niemandem
|
||||
* mehr erklären.
|
||||
*/
|
||||
|
||||
import { ChevronRight, File, Folder, FolderOpen } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useApiResource } from '@/api/useApiResource';
|
||||
import { describeApiError } from '@/api/useMutation';
|
||||
import { Button, Callout, ErrorState, LoadingState } from '@/components/ui';
|
||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
||||
import { browseBackupContents } from './browseApi';
|
||||
|
||||
export function BackupContentPicker({
|
||||
backupIdentifier,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}: {
|
||||
readonly backupIdentifier: string;
|
||||
/** Leer bedeutet: das gesamte Backup. */
|
||||
readonly selectedPath: string;
|
||||
readonly onSelect: (contentPath: string) => void;
|
||||
}) {
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
|
||||
const contentsResource = useApiResource(
|
||||
useCallback(
|
||||
(abortSignal) => browseBackupContents(backupIdentifier, currentPath, abortSignal),
|
||||
[backupIdentifier, currentPath],
|
||||
),
|
||||
`${backupIdentifier}|${currentPath}`,
|
||||
);
|
||||
|
||||
const listing = contentsResource.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Der Regelfall steht oben und ist vorausgewählt: Die meisten
|
||||
Wiederherstellungen holen alles zurück. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPath === '' ? 'primary' : 'secondary'}
|
||||
onClick={() => onSelect('')}
|
||||
>
|
||||
Gesamtes Backup
|
||||
</Button>
|
||||
<span className="text-xs text-fg-muted">
|
||||
{listing ? `${listing.total_entry_count.toLocaleString('de-DE')} Objekte` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pfadleiste */}
|
||||
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="text-fg-muted hover:text-fg"
|
||||
onClick={() => setCurrentPath('')}
|
||||
>
|
||||
Wurzel
|
||||
</button>
|
||||
{currentPath
|
||||
.split('/')
|
||||
.filter((segment) => segment !== '')
|
||||
.map((segment, segmentIndex, allSegments) => (
|
||||
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
|
||||
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className="text-fg-muted hover:text-fg"
|
||||
onClick={() => setCurrentPath(allSegments.slice(0, segmentIndex + 1).join('/'))}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto border border-line">
|
||||
{contentsResource.loadState === 'loading' ? (
|
||||
<LoadingState label="Inhalt wird gelesen …" />
|
||||
) : contentsResource.loadState === 'failed' && contentsResource.loadError ? (
|
||||
<ErrorState
|
||||
message={describeApiError(contentsResource.loadError)}
|
||||
requestId={contentsResource.loadError.requestId}
|
||||
onRetry={contentsResource.reload}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{currentPath !== '' ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
|
||||
onClick={() => setCurrentPath(listing?.parent_path ?? '')}
|
||||
>
|
||||
<FolderOpen className="size-4 shrink-0" aria-hidden />
|
||||
… eine Ebene höher
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{(listing?.entries ?? []).map((entry) => (
|
||||
<li key={entry.path} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
|
||||
onClick={() =>
|
||||
entry.is_directory ? setCurrentPath(entry.path) : onSelect(entry.path)
|
||||
}
|
||||
>
|
||||
{entry.is_directory ? (
|
||||
<Folder className="size-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
) : (
|
||||
<File className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{entry.name}</span>
|
||||
|
||||
<span className="shrink-0 text-[11px] tabular text-fg-muted">
|
||||
{entry.is_directory
|
||||
? entry.child_count
|
||||
? `${entry.child_count} Objekte · ${formatBytes(entry.total_bytes)}`
|
||||
: ''
|
||||
: formatBytes(entry.size_bytes)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
|
||||
className="mr-1 shrink-0"
|
||||
onClick={() => onSelect(entry.path)}
|
||||
title={
|
||||
entry.is_directory
|
||||
? 'Diesen Ordner mit allem darin zurückholen'
|
||||
: 'Nur diese Datei zurückholen'
|
||||
}
|
||||
>
|
||||
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{(listing?.entries ?? []).length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-fg-muted">
|
||||
Dieser Ordner ist im Backup leer.
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPath ? (
|
||||
<Callout tone="info">
|
||||
Zurückgeholt wird nur: <code className="font-mono">{selectedPath}</code>
|
||||
<p className="mt-1 text-xs">
|
||||
Ein auf einen Teilbaum beschränkter Lauf hebt die Einstufung des
|
||||
Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das
|
||||
Backup.
|
||||
</p>
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout tone="info">Zurückgeholt wird das gesamte Backup.</Callout>
|
||||
)}
|
||||
|
||||
{listing?.entries.some((entry) => entry.modified_at) ? (
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Stand der Dateien:{' '}
|
||||
{formatDateTime(
|
||||
listing.entries.find((entry) => entry.modified_at)?.modified_at,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,220 +0,0 @@
|
||||
/**
|
||||
* Auswahl des Zielverzeichnisses.
|
||||
*
|
||||
* Ersetzt das Textfeld, in das man einen Pfad tippte und erst nach der
|
||||
* Vorabprüfung erfuhr, dass der Dienst dort gar nicht schreiben darf.
|
||||
*
|
||||
* Die tragende Angabe ist **`is_writable`**, und sie wird gemessen: Der Server
|
||||
* legt eine Probedatei an und entfernt sie wieder. Aus den Rechtebits ließe sie
|
||||
* sich nicht ableiten — der Dienst läuft mit `ProtectSystem=strict`, und davon
|
||||
* steht nichts im Modus. Genau deshalb endete eine Wiederherstellung nach
|
||||
* `/opt/test` mit „permission denied", obwohl das Verzeichnis beschreibbar
|
||||
* aussah.
|
||||
*
|
||||
* Nicht beschreibbare Verzeichnisse werden **gezeigt**, nicht versteckt: Sie
|
||||
* lassen sich betreten, um tiefer zu blättern, aber nicht auswählen. Sie
|
||||
* wegzulassen ließe den Betreiber im Dunkeln, warum sein Pfad fehlt.
|
||||
*/
|
||||
|
||||
import { ChevronRight, FolderOpen, Lock, Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useApiResource } from '@/api/useApiResource';
|
||||
import { describeApiError } from '@/api/useMutation';
|
||||
import {
|
||||
Button,
|
||||
Callout,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
TextInput,
|
||||
} from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { browseFilesystem } from './browseApi';
|
||||
|
||||
export function DirectoryPicker({
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}: {
|
||||
readonly selectedPath: string;
|
||||
readonly onSelect: (directoryPath: string) => void;
|
||||
}) {
|
||||
// Der Startpunkt ist das übergeordnete Verzeichnis der Auswahl, sonst die
|
||||
// Wurzel — so landet man beim zweiten Öffnen dort, wo man aufgehört hat.
|
||||
const [currentPath, setCurrentPath] = useState(() => {
|
||||
const trimmedSelection = selectedPath.trim();
|
||||
|
||||
if (!trimmedSelection.startsWith('/')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const parentPath = trimmedSelection.replace(/\/[^/]*$/, '');
|
||||
|
||||
return parentPath === '' ? '/' : parentPath;
|
||||
});
|
||||
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
|
||||
const browseResource = useApiResource(
|
||||
useCallback((abortSignal) => browseFilesystem(currentPath, abortSignal), [currentPath]),
|
||||
currentPath,
|
||||
);
|
||||
|
||||
const listing = browseResource.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Vorschläge zuerst: Sie sind der einzige Weg, ohne Vorwissen an einen
|
||||
brauchbaren Ort zu kommen. */}
|
||||
{listing?.suggested_paths && listing.suggested_paths.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-fg-subtle">Beschreibbare Orte</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{listing.suggested_paths.map((suggestedPath) => (
|
||||
<Button
|
||||
key={suggestedPath}
|
||||
size="sm"
|
||||
variant={selectedPath === suggestedPath ? 'primary' : 'secondary'}
|
||||
onClick={() => {
|
||||
onSelect(suggestedPath);
|
||||
setCurrentPath(suggestedPath);
|
||||
}}
|
||||
>
|
||||
{suggestedPath}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Pfadleiste */}
|
||||
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="text-fg-muted hover:text-fg"
|
||||
onClick={() => setCurrentPath('/')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
{currentPath
|
||||
.split('/')
|
||||
.filter((segment) => segment !== '')
|
||||
.map((segment, segmentIndex, allSegments) => (
|
||||
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
|
||||
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className="text-fg-muted hover:text-fg"
|
||||
onClick={() =>
|
||||
setCurrentPath('/' + allSegments.slice(0, segmentIndex + 1).join('/'))
|
||||
}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div className="max-h-64 overflow-y-auto border border-line">
|
||||
{browseResource.loadState === 'loading' ? (
|
||||
<LoadingState label="Verzeichnis wird gelesen …" />
|
||||
) : browseResource.loadState === 'failed' && browseResource.loadError ? (
|
||||
<ErrorState
|
||||
message={describeApiError(browseResource.loadError)}
|
||||
requestId={browseResource.loadError.requestId}
|
||||
onRetry={browseResource.reload}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{listing?.parent_path ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
|
||||
onClick={() => setCurrentPath(listing.parent_path!)}
|
||||
>
|
||||
<FolderOpen className="size-4 shrink-0" aria-hidden />
|
||||
… eine Ebene höher
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{(listing?.entries ?? []).map((entry) => (
|
||||
<li key={entry.path} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
|
||||
onClick={() => setCurrentPath(entry.path)}
|
||||
title={entry.forbidden_reason}
|
||||
>
|
||||
{entry.is_writable ? (
|
||||
<FolderOpen className="size-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
) : (
|
||||
<Lock className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
||||
)}
|
||||
<span className={cn('truncate', entry.is_writable ? 'text-fg' : 'text-fg-subtle')}>
|
||||
{entry.name}
|
||||
</span>
|
||||
{!entry.is_writable ? (
|
||||
<span className="ml-auto shrink-0 text-[11px] text-fg-subtle">
|
||||
{entry.forbidden_reason ? 'gesperrt' : 'nicht beschreibbar'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{entry.is_writable ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
|
||||
className="mr-1 shrink-0"
|
||||
onClick={() => onSelect(entry.path)}
|
||||
>
|
||||
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
|
||||
{(listing?.entries ?? []).length === 0 && !listing?.parent_path ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-fg-muted">
|
||||
Keine Unterverzeichnisse.
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Neues Unterverzeichnis: Der Dienst legt das Ziel selbst an, wenn er
|
||||
im übergeordneten Verzeichnis schreiben darf. Der Name wandert einfach
|
||||
an den aktuellen Pfad. */}
|
||||
<div className="flex items-end gap-2">
|
||||
<TextInput
|
||||
label="Neues Unterverzeichnis"
|
||||
className="flex-1"
|
||||
value={newFolderName}
|
||||
onChange={(changeEvent) => setNewFolderName(changeEvent.target.value)}
|
||||
placeholder="wiederherstellung-2026-08-18"
|
||||
hint="Es wird beim Zurückschreiben angelegt."
|
||||
/>
|
||||
<Button
|
||||
className="mb-0.5"
|
||||
disabled={!newFolderName.trim()}
|
||||
onClick={() => {
|
||||
const combinedPath =
|
||||
(currentPath === '/' ? '' : currentPath) + '/' + newFolderName.trim();
|
||||
onSelect(combinedPath);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
Übernehmen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedPath ? (
|
||||
<Callout tone="info">
|
||||
Ziel: <code className="font-mono">{selectedPath}</code>
|
||||
</Callout>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -41,8 +41,6 @@ import {
|
||||
useToast,
|
||||
} from '@/components/ui';
|
||||
import { cn, formatBytes, formatDuration } from '@/lib/utils';
|
||||
import { BackupContentPicker } from './BackupContentPicker';
|
||||
import { DirectoryPicker } from './DirectoryPicker';
|
||||
import {
|
||||
createRestore,
|
||||
validateRestore,
|
||||
@ -172,15 +170,35 @@ export function RestoreWizard({
|
||||
|
||||
<DialogBody className="min-h-64">
|
||||
{currentStepIndex === 0 ? (
|
||||
<DirectoryPicker selectedPath={targetPath} onSelect={setTargetPath} />
|
||||
<div className="space-y-4">
|
||||
<TextInput
|
||||
label="Zielverzeichnis"
|
||||
required
|
||||
placeholder="/srv/wiederherstellung"
|
||||
value={targetPath}
|
||||
onChange={(changeEvent) => setTargetPath(changeEvent.target.value)}
|
||||
hint="Absoluter Pfad auf dem Server. Systemverzeichnisse wie /etc oder /usr werden abgelehnt."
|
||||
error={
|
||||
targetPath.trim() && !targetPath.trim().startsWith('/')
|
||||
? 'Der Pfad muss absolut sein und mit / beginnen.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Callout tone="info" title="Wohin am besten?">
|
||||
Am besten in ein leeres Verzeichnis — der Ursprungsort wäre sonst überschrieben.
|
||||
</Callout>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentStepIndex === 1 ? (
|
||||
<div className="space-y-4">
|
||||
<BackupContentPicker
|
||||
backupIdentifier={backupIdentifier}
|
||||
selectedPath={pathPrefix}
|
||||
onSelect={setPathPrefix}
|
||||
<TextInput
|
||||
label="Nur ein Teilbaum (optional)"
|
||||
placeholder="daten/projekte"
|
||||
value={pathPrefix}
|
||||
onChange={(changeEvent) => setPathPrefix(changeEvent.target.value)}
|
||||
hint="Leer lassen, um alles zurückzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das Backup."
|
||||
/>
|
||||
|
||||
<div className="space-y-3 border-t border-line pt-4">
|
||||
@ -191,8 +209,8 @@ export function RestoreWizard({
|
||||
label="Vorhandene Dateien überschreiben"
|
||||
hint={
|
||||
mayOverwrite
|
||||
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt.'
|
||||
: 'Ihrer Rolle fehlt das Recht restores.overwrite.'
|
||||
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusätzlich verlangt der Server danach den wörtlich wiederholten Zielpfad.'
|
||||
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
|
||||
}
|
||||
/>
|
||||
|
||||
@ -200,16 +218,24 @@ export function RestoreWizard({
|
||||
checked={skipPermissions}
|
||||
onCheckedChange={setSkipPermissions}
|
||||
label="Rechte nicht zurückschreiben"
|
||||
hint="Dateien entstehen mit den Standardrechten des Dienstkontos."
|
||||
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
checked={skipDeepCheck}
|
||||
onCheckedChange={setSkipDeepCheck}
|
||||
label="Blockprüfung überspringen"
|
||||
hint="Schneller, aber dann ist nicht belegt, dass die Daten noch da sind."
|
||||
hint="Beschleunigt die Vorabprüfung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benötigte Block noch vorhanden ist."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{skipDeepCheck ? (
|
||||
<Callout tone="warning">
|
||||
Ohne Blockprüfung sagt die Vorabprüfung nur, dass das
|
||||
Manifest lesbar ist — nicht, dass die Daten dazu noch
|
||||
existieren. Der Bericht weist das aus.
|
||||
</Callout>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Blättern im Dateisystem des Servers und im Inhalt eines Backups.
|
||||
*
|
||||
* Beides gehört zur Wiederherstellung: Wohin darf zurückgeschrieben werden, und
|
||||
* was steckt überhaupt in dem Backup? Ein Textfeld beantwortet weder das eine
|
||||
* noch das andere.
|
||||
*/
|
||||
|
||||
import { requestApi } from '../../api/client';
|
||||
|
||||
/** Ein Verzeichnis auf dem Server. */
|
||||
export interface FilesystemEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
/**
|
||||
* Meldet, ob der **Dienst** hier anlegen darf.
|
||||
*
|
||||
* Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten: Der
|
||||
* Dienst läuft mit `ProtectSystem=strict`, und davon steht nichts im Modus.
|
||||
*/
|
||||
is_writable: boolean;
|
||||
/** Grund, wenn der Zielschutz den Ort ausschließt. */
|
||||
forbidden_reason?: string;
|
||||
}
|
||||
|
||||
/** Antwort auf das Blättern im Dateisystem. */
|
||||
export interface BrowseFilesystemResponse {
|
||||
path: string;
|
||||
parent_path?: string;
|
||||
entries: FilesystemEntry[];
|
||||
/** Orte, an denen der Dienst nachweislich schreiben darf. */
|
||||
suggested_paths?: string[];
|
||||
}
|
||||
|
||||
/** Ein Eintrag im Inhaltsverzeichnis eines Backups. */
|
||||
export interface BackupContentEntry {
|
||||
name: string;
|
||||
/** Genau der Wert, den eine Wiederherstellung als `path_prefix` erwartet. */
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
entry_type: string;
|
||||
size_bytes?: number;
|
||||
modified_at?: string;
|
||||
mode?: string;
|
||||
/** Zahl der Einträge unterhalb eines Verzeichnisses. */
|
||||
child_count?: number;
|
||||
/** Datenmenge unterhalb eines Verzeichnisses. */
|
||||
total_bytes?: number;
|
||||
}
|
||||
|
||||
/** Antwort auf das Blättern im Backup. */
|
||||
export interface BrowseBackupResponse {
|
||||
backup_id: string;
|
||||
path: string;
|
||||
parent_path?: string;
|
||||
entries: BackupContentEntry[];
|
||||
total_entry_count: number;
|
||||
}
|
||||
|
||||
/** Listet die Verzeichnisse unterhalb eines Pfades. */
|
||||
export async function browseFilesystem(
|
||||
directoryPath: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<BrowseFilesystemResponse> {
|
||||
return requestApi<BrowseFilesystemResponse>(
|
||||
`/filesystem/browse?path=${encodeURIComponent(directoryPath)}`,
|
||||
abortSignal ? { signal: abortSignal } : {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Listet den Inhalt eines Backups auf einer Ebene. */
|
||||
export async function browseBackupContents(
|
||||
backupIdentifier: string,
|
||||
contentPath: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<BrowseBackupResponse> {
|
||||
return requestApi<BrowseBackupResponse>(
|
||||
`/backups/${encodeURIComponent(backupIdentifier)}/contents?path=${encodeURIComponent(contentPath)}`,
|
||||
abortSignal ? { signal: abortSignal } : {},
|
||||
);
|
||||
}
|
||||
@ -15,10 +15,11 @@
|
||||
* 2. **Das dunkle Thema hängt an `[data-theme='dark']`, nicht an `.dark`.** Der
|
||||
* Umschalter der Konsole setzt dieses Attribut, und er ist getestet. Beide
|
||||
* Schreibweisen werden unterstützt.
|
||||
* 3. **Geist Mono liegt im Paket.** Sie wird vom eigenen Ursprung ausgeliefert,
|
||||
* nicht von einem CDN: Das verlangt die Content-Security-Policy, und ein
|
||||
* Backup-Server, dessen Oberfläche von der Erreichbarkeit eines fremden
|
||||
* Hosts abhängt, wäre auch ohne CSP falsch.
|
||||
* 3. **Die Schrift lädt nicht nach.** Geist Mono steht zuerst im Stapel und
|
||||
* wird verwendet, wenn sie auf dem Gerät liegt; sonst greift die
|
||||
* System-Monospace. Eine Schrift von einem fremden Host zu holen verbietet
|
||||
* die Content-Security-Policy der Auslieferung — und ein Backup-Server, der
|
||||
* für seine Oberfläche ins Internet greift, wäre auch ohne CSP falsch.
|
||||
*
|
||||
* Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`)
|
||||
* statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets
|
||||
@ -28,45 +29,6 @@
|
||||
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* Geist Mono liegt **im Paket**, nicht auf einem fremden Host.
|
||||
*
|
||||
* Vite bündelt die drei Schnitte mit und vergibt ihnen einen Hash; ausgeliefert
|
||||
* wird ausschließlich vom eigenen Ursprung. Das ist die einzige Form, die die
|
||||
* Content-Security-Policy zulässt — und die einzige, die für einen
|
||||
* Backup-Server vertretbar ist: Seine Oberfläche darf nicht davon abhängen,
|
||||
* dass ein CDN erreichbar ist. Kosten: rund 128 KB für drei Schnitte.
|
||||
*
|
||||
* `font-display: swap` zeigt den Text sofort in der System-Monospace und
|
||||
* tauscht die Schrift nach. Ein unsichtbarer Text, bis eine Schriftdatei da
|
||||
* ist, wäre in einer Störungskonsole der falsche Kompromiss.
|
||||
*
|
||||
* Lizenz: SIL Open Font License 1.1, siehe assets/fonts/LICENSE.txt.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('../assets/fonts/GeistMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('../assets/fonts/GeistMono-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('../assets/fonts/GeistMono-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Beide Schreibweisen: Der Umschalter setzt `data-theme`, `.dark` ist die
|
||||
* shadcn-übliche Form und kostet nichts. */
|
||||
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *));
|
||||
|
||||
@ -89,46 +89,6 @@ Wiederherstellung: 355 MiB/s, Ergebnis bitgenau identisch zur Quelle.
|
||||
|
||||
Ein früherer Messlauf zeigte 1021-fache Kompression. Diese Zahl war wertlos: die Testdaten waren periodisch erzeugt und damit unrealistisch gut komprimierbar. Belastbar sind nur Messungen mit inkompressiblen Daten.
|
||||
|
||||
## Sicherungsart je Auftrag
|
||||
|
||||
Der Standard ist **inkrementell**: Liegt ein Elternbackup vor, wird nur
|
||||
Geändertes gelesen. Zwei Abweichungen lassen sich je Auftrag einstellen.
|
||||
|
||||
| Einstellung | Wirkung |
|
||||
| --- | --- |
|
||||
| `incremental` | Erster Lauf voll, danach inkrementell. Standard |
|
||||
| `always_full` | Jeder Lauf liest die gesamte Quelle |
|
||||
| `full_backup_weekday` | Zusätzlich an einem festen Wochentag voll |
|
||||
|
||||
**Der Platzbedarf steigt bei „immer voll" nicht nennenswert.** Unveränderte
|
||||
Blöcke werden dedupliziert und liegen weiterhin nur einmal im Repository. Was
|
||||
steigt, ist die **Laufzeit**: Jeder Lauf liest, hasht, komprimiert und
|
||||
verschlüsselt alles neu. Gemessen (Phase 6): 190,7 MiB in 1 815 ms gegen
|
||||
4,8 MiB in 133 ms bei einer geänderten von 41 Dateien.
|
||||
|
||||
Wer das verwechselt, hält „immer voll" für teuer im Speicher und plant seinen
|
||||
Nachtbetrieb falsch.
|
||||
|
||||
Wann eine Abweichung sinnvoll ist:
|
||||
|
||||
- **Immer voll**, wenn das Repository außer Haus geht oder auf einen
|
||||
Datenträger geschrieben wird, der einzeln weggetragen wird.
|
||||
- **Wöchentlich voll** als üblicher Kompromiss: unter der Woche schnell, an
|
||||
einem festen Tag einmal vollständig.
|
||||
|
||||
Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Ohne diese
|
||||
Umrechnung liefe derselbe Auftrag auf zwei Servern an verschiedenen Tagen voll
|
||||
— und ein Betreiber in Berlin bekäme seine Vollsicherung am Donnerstagabend.
|
||||
|
||||
Ein Widerspruch — „immer voll" **und** ein Wochentag — wird von der Datenbank
|
||||
abgelehnt, nicht stillschweigend aufgelöst.
|
||||
|
||||
Zur Einordnung: Syncova-Manifeste sind vollständig (Phase 6). Eine
|
||||
Zusatzsicherung trägt die Blockverweise des Elternbackups mit, ein Restore liest
|
||||
genau **ein** Manifest, und das Löschen eines alten Backups kann ein neueres
|
||||
nicht beschädigen. Der Unterschied liegt also in der Laufzeit, nicht in der
|
||||
Wiederherstellbarkeit.
|
||||
|
||||
## Grenzen des aktuellen Stands
|
||||
|
||||
**Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte:
|
||||
|
||||
@ -187,17 +187,12 @@ ExecStart=/opt/syncova/bin/syncova-api
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Härtung: Der Dienst braucht Netz, sein Repository und eine Fläche für
|
||||
# Wiederherstellungen — sonst nichts.
|
||||
#
|
||||
# ReadWritePaths ist die Stelle, an der eine Wiederherstellung scheitert, wenn
|
||||
# man sie vergisst: Mit ProtectSystem=strict ist alles andere für den Dienst
|
||||
# schreibgeschützt, und die Rechte des Zielverzeichnisses helfen dann nicht.
|
||||
# Härtung: Der Dienst braucht Netz und sein Repository, sonst nichts.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/srv/syncova-repository /srv/syncova-restore
|
||||
ReadWritePaths=/srv/syncova-repository
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
@ -290,25 +285,6 @@ sudo firewall-cmd --permanent --add-service=https # firewalld
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
## 8b. Wohin Wiederherstellungen schreiben dürfen
|
||||
|
||||
`setup.sh` legt `/srv/syncova-restore` an und trägt es in `ReadWritePaths` der
|
||||
systemd-Einheit ein. Ohne diesen Eintrag scheitert **jede** Wiederherstellung:
|
||||
Der Dienst läuft mit `ProtectSystem=strict`, und die Rechte des
|
||||
Zielverzeichnisses helfen dann nicht.
|
||||
|
||||
Weitere Ziele beim Einrichten nennen:
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --wiederherstellungsziel /srv/wiederherstellung \
|
||||
--wiederherstellungsziel /mnt/nas/restore
|
||||
```
|
||||
|
||||
Nicht möglich sind `/tmp` (privater Namensraum des Dienstes) und die
|
||||
Systemverzeichnisse `/etc`, `/usr`, `/var/lib`, `/root` — Letztere sperrt der
|
||||
Zielschutz, weil eine Wiederherstellung dorthin das System überschriebe, auf dem
|
||||
die Anlage läuft.
|
||||
|
||||
## 9. Erstes Repository
|
||||
|
||||
Ein Repository entsteht **auf einem Datenträger**, nicht in einer
|
||||
|
||||
@ -51,44 +51,9 @@ Original nicht mehr.
|
||||
|
||||
## 1. Dateien und Ordner
|
||||
|
||||
### Wohin darf zurückgeschrieben werden?
|
||||
|
||||
**Die wichtigste Frage, und sie ist nicht offensichtlich.** Der Dienst läuft mit
|
||||
`ProtectSystem=strict`: Außerhalb weniger Pfade ist das Dateisystem für ihn
|
||||
schreibgeschützt, unabhängig von den Rechten des Verzeichnisses. Ein Ziel
|
||||
außerhalb endet mit `mkdir: permission denied` — und zwar erst **nach** der
|
||||
Vorabprüfung.
|
||||
|
||||
| Ort | Ergebnis |
|
||||
| --- | --- |
|
||||
| `/srv/syncova-restore` | ✓ von `setup.sh` angelegt und eingetragen |
|
||||
| weitere aus `--wiederherstellungsziel` | ✓ |
|
||||
| `/tmp/…` | ✗ landet im privaten `/tmp` des Dienstes und ist von außen unsichtbar |
|
||||
| `/etc`, `/usr`, `/var/lib`, `/root` … | ✗ vom Zielschutz gesperrt |
|
||||
| alles andere | ✗ schreibgeschützt durch `ProtectSystem=strict` |
|
||||
|
||||
Der Ordnerbaum in der Oberfläche beantwortet das direkt: Er meldet je
|
||||
Verzeichnis, ob der Dienst dort schreiben darf — **gemessen** durch eine
|
||||
Probedatei, nicht aus den Rechtebits abgeleitet.
|
||||
|
||||
Ein weiteres Ziel nachträglich freigeben:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit syncova-api # ReadWritePaths= ergänzen
|
||||
sudo systemctl restart syncova-api
|
||||
```
|
||||
|
||||
### Über die Oberfläche
|
||||
|
||||
Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen*.
|
||||
|
||||
1. **Ziel** über den Ordnerbaum wählen. Beschreibbare Orte stehen oben als
|
||||
Vorschlag; ein Unterverzeichnis lässt sich anlegen.
|
||||
2. **Umfang** über den Backup-Browser wählen — das gesamte Backup, ein Ordner
|
||||
oder eine einzelne Datei.
|
||||
3. **Vorabprüfung** — sie schreibt nichts und stellt fest, ob jeder benötigte
|
||||
Block noch da ist.
|
||||
4. **Ausführen.**
|
||||
Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen* → Zielpfad angeben.
|
||||
|
||||
### Über die API
|
||||
|
||||
@ -103,16 +68,7 @@ curl -X POST https://<server>/api/v1/restores \
|
||||
}'
|
||||
```
|
||||
|
||||
`path_prefix` trifft **einen Ordner oder eine einzelne Datei**: Der Server
|
||||
vergleicht auf Gleichheit oder Präfix mit Verzeichnisgrenze — `dokumente`
|
||||
trifft dabei nicht `dokumentation`. Ohne ihn kommt alles zurück.
|
||||
|
||||
Den Inhalt eines Backups durchsehen, ohne etwas zurückzuschreiben:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer <token>" \
|
||||
"https://<server>/api/v1/backups/<id>/contents?path=berichte" | jq '.data.entries'
|
||||
```
|
||||
`path_prefix` beschränkt auf einen Teilbaum. Ohne ihn kommt alles zurück.
|
||||
|
||||
Fortschritt:
|
||||
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
ALTER TABLE backup_jobs
|
||||
DROP CONSTRAINT IF EXISTS backup_jobs_full_weekday_only_incremental,
|
||||
DROP CONSTRAINT IF EXISTS backup_jobs_full_backup_weekday_valid,
|
||||
DROP CONSTRAINT IF EXISTS backup_jobs_backup_mode_valid;
|
||||
|
||||
ALTER TABLE backup_jobs
|
||||
DROP COLUMN IF EXISTS full_backup_weekday,
|
||||
DROP COLUMN IF EXISTS backup_mode;
|
||||
@ -1,48 +0,0 @@
|
||||
-- Sicherungsart je Auftrag.
|
||||
--
|
||||
-- Bisher entschied der Executor allein: Liegt ein Elternbackup vor, wird
|
||||
-- inkrementell gesichert. Das ist der richtige Standard — der Gewinn ist
|
||||
-- Lesezeit, und die ist nach dem ersten Lauf der begrenzende Faktor.
|
||||
--
|
||||
-- Es gibt aber zwei Gruende, davon abzuweichen, und beide sind betrieblich:
|
||||
--
|
||||
-- 1. **Immer voll.** Wer sein Backup ausser Haus gibt oder auf einen
|
||||
-- Datentraeger schreibt, der einzeln weggetragen wird, will nicht, dass
|
||||
-- ein Wiederherstellungspunkt an einem frueheren haengt.
|
||||
-- 2. **Wöchentlich voll.** Der uebliche Kompromiss: unter der Woche schnell,
|
||||
-- am Wochenende einmal vollstaendig.
|
||||
--
|
||||
-- Hinweis zur Einordnung: Syncova-Manifeste sind **vollstaendig** (Phase 6).
|
||||
-- Eine Zusatzsicherung traegt die Blockverweise des Elternbackups mit, ein
|
||||
-- Restore liest genau ein Manifest, und das Loeschen eines alten Backups kann
|
||||
-- ein neueres nicht beschaedigen. Der Unterschied liegt also in der Laufzeit,
|
||||
-- nicht in der Wiederherstellbarkeit.
|
||||
|
||||
ALTER TABLE backup_jobs
|
||||
-- 'incremental' = nach dem ersten Lauf inkrementell (Standard, bisheriges
|
||||
-- Verhalten). 'always_full' = jeder Lauf liest die Quelle vollstaendig.
|
||||
ADD COLUMN backup_mode TEXT NOT NULL DEFAULT 'incremental',
|
||||
|
||||
-- Wochentag, an dem zusaetzlich eine Vollsicherung erzwungen wird.
|
||||
-- 0 = Sonntag … 6 = Samstag, NULL = keiner. Gerechnet in der Zeitzone des
|
||||
-- Zeitplans; ohne Angabe in UTC — sonst liefe derselbe Auftrag auf zwei
|
||||
-- Servern an verschiedenen Tagen voll.
|
||||
ADD COLUMN full_backup_weekday SMALLINT;
|
||||
|
||||
ALTER TABLE backup_jobs
|
||||
ADD CONSTRAINT backup_jobs_backup_mode_valid
|
||||
CHECK (backup_mode IN ('incremental', 'always_full')),
|
||||
|
||||
ADD CONSTRAINT backup_jobs_full_backup_weekday_valid
|
||||
CHECK (full_backup_weekday IS NULL OR full_backup_weekday BETWEEN 0 AND 6),
|
||||
|
||||
-- Ein Wochentag bei 'always_full' ist widersprüchlich: Dann ist ohnehin
|
||||
-- jeder Lauf voll. Die Regel steht in der Datenbank, weil im Code jede
|
||||
-- Stelle sie einhalten muesste — und eine vergisst es.
|
||||
ADD CONSTRAINT backup_jobs_full_weekday_only_incremental
|
||||
CHECK (backup_mode = 'incremental' OR full_backup_weekday IS NULL);
|
||||
|
||||
COMMENT ON COLUMN backup_jobs.backup_mode IS
|
||||
'incremental = nach dem ersten Lauf inkrementell; always_full = jeder Lauf vollstaendig';
|
||||
COMMENT ON COLUMN backup_jobs.full_backup_weekday IS
|
||||
'Wochentag einer erzwungenen Vollsicherung (0=Sonntag), NULL = keiner';
|
||||
@ -37,5 +37,3 @@ e559f3d4443b9057a3f40a685141d92f3e05817da725857d2f0cba231aef3035 000005_restore
|
||||
e6568d4614f3ebf5a66b75f2b8b3b0ee80786c04c6165216964395c7c1834db8 000009_alerts.up.sql
|
||||
f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql
|
||||
ffbd9245f8031c902f30a81cbaffcf9e61e515cc77ccaf9e885186e737cca3d0 000003_agents.up.sql
|
||||
f25274d148996038f5716f315470c4f9371629ad734a03f0ee49ee760ea4c2d5 000014_backup_mode.up.sql
|
||||
9a48a9de411d6af07461c236967525469c73d820a4d282fab8a61ed6eb56f9f3 000014_backup_mode.down.sql
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
package backupexecutor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/syncova/syncova/packages/jobs"
|
||||
"github.com/syncova/syncova/packages/scheduler"
|
||||
)
|
||||
|
||||
// TestAlwaysFullForcesEveryRun haelt die dauerhafte Vollsicherung fest.
|
||||
func TestAlwaysFullForcesEveryRun(testInstance *testing.T) {
|
||||
fullJob := &jobs.Job{BackupMode: jobs.BackupModeAlwaysFull}
|
||||
|
||||
// An jedem beliebigen Tag.
|
||||
for dayOffset := 0; dayOffset < 7; dayOffset++ {
|
||||
runTime := time.Date(2026, 8, 17, 2, 0, 0, 0, time.UTC).AddDate(0, 0, dayOffset)
|
||||
|
||||
if !shouldForceFullBackup(fullJob, runTime) {
|
||||
testInstance.Errorf("am %s wurde keine Vollsicherung erzwungen", runTime.Weekday())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIncrementalIsTheDefault haelt fest, dass ohne Angabe nichts erzwungen wird.
|
||||
//
|
||||
// Der Standard ist das bisherige Verhalten: Liegt ein Elternbackup vor, wird
|
||||
// inkrementell gesichert. Ein Auftrag aus der Zeit vor dieser Einstellung darf
|
||||
// sich nicht ploetzlich anders verhalten.
|
||||
func TestIncrementalIsTheDefault(testInstance *testing.T) {
|
||||
if shouldForceFullBackup(&jobs.Job{}, time.Now()) {
|
||||
testInstance.Error("ohne Angabe wurde eine Vollsicherung erzwungen")
|
||||
}
|
||||
|
||||
if shouldForceFullBackup(nil, time.Now()) {
|
||||
testInstance.Error("ohne Auftrag wurde eine Vollsicherung erzwungen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullBackupWeekdayUsesScheduleTimeZone ist der eigentliche Punkt.
|
||||
//
|
||||
// Der Wochentag muss in der Zeitzone des Zeitplans bestimmt werden. Rechnete
|
||||
// der Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
|
||||
// Donnerstagabend — und wunderte sich, warum sie freitags fehlt.
|
||||
func TestFullBackupWeekdayUsesScheduleTimeZone(testInstance *testing.T) {
|
||||
friday := time.Friday
|
||||
|
||||
berlinJob := &jobs.Job{
|
||||
FullBackupWeekday: &friday,
|
||||
Schedule: scheduler.Schedule{TimeZone: "Europe/Berlin"},
|
||||
}
|
||||
|
||||
// Donnerstag, 23:30 UTC — in Berlin ist es bereits Freitag, 01:30.
|
||||
thursdayNightUTC := time.Date(2026, 8, 20, 23, 30, 0, 0, time.UTC)
|
||||
|
||||
if thursdayNightUTC.Weekday() != time.Thursday {
|
||||
testInstance.Fatalf("Testvoraussetzung falsch: %s", thursdayNightUTC.Weekday())
|
||||
}
|
||||
|
||||
if !shouldForceFullBackup(berlinJob, thursdayNightUTC) {
|
||||
testInstance.Error("die Zeitzone des Zeitplans wird nicht beachtet: " +
|
||||
"in Berlin ist Freitag, in UTC noch Donnerstag")
|
||||
}
|
||||
|
||||
// Und umgekehrt: Freitag 23:30 UTC ist in Berlin schon Samstag.
|
||||
fridayNightUTC := time.Date(2026, 8, 21, 23, 30, 0, 0, time.UTC)
|
||||
|
||||
if shouldForceFullBackup(berlinJob, fridayNightUTC) {
|
||||
testInstance.Error("es wurde am Samstag (Berliner Zeit) voll gesichert")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullBackupWeekdayFallsBackToUTC haelt den Fall ohne Zeitzone fest.
|
||||
//
|
||||
// Ohne Angabe gilt UTC, nicht die Ortszeit des Servers — sonst liefe dieselbe
|
||||
// Konfiguration auf zwei Servern an verschiedenen Tagen voll.
|
||||
func TestFullBackupWeekdayFallsBackToUTC(testInstance *testing.T) {
|
||||
sunday := time.Sunday
|
||||
|
||||
utcJob := &jobs.Job{FullBackupWeekday: &sunday}
|
||||
|
||||
sundayUTC := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if sundayUTC.Weekday() != time.Sunday {
|
||||
testInstance.Fatalf("Testvoraussetzung falsch: %s", sundayUTC.Weekday())
|
||||
}
|
||||
|
||||
if !shouldForceFullBackup(utcJob, sundayUTC) {
|
||||
testInstance.Error("am Sonntag wurde keine Vollsicherung erzwungen")
|
||||
}
|
||||
}
|
||||
@ -423,13 +423,6 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
|
||||
return jobs.ExecutionResult{}, parentError
|
||||
}
|
||||
|
||||
// Der Auftrag kann davon abweichen — dauerhaft oder an einem Wochentag.
|
||||
//
|
||||
// Der Platzbedarf steigt dadurch nicht nennenswert: Unveraenderte Bloecke
|
||||
// werden dedupliziert und liegen weiterhin nur einmal im Repository. Was
|
||||
// steigt, ist die Laufzeit.
|
||||
forceFullBackup := shouldForceFullBackup(backupRequest.Job, time.Now())
|
||||
|
||||
backupIdentifier := buildBackupIdentifier(backupRequest.RunID, backupRequest.Source)
|
||||
|
||||
startTime := time.Now().UTC()
|
||||
@ -446,7 +439,7 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
|
||||
EncryptionEnabled: executor.options.SecretStore != nil,
|
||||
ChainID: chainIdentifier.String(),
|
||||
CreatedByVersion: executor.options.CreatedByVersion,
|
||||
Incremental: parentBackupInRepository != "" && !forceFullBackup,
|
||||
Incremental: parentBackupInRepository != "",
|
||||
ParentBackupID: parentBackupInRepository,
|
||||
BandwidthLimiter: backupRequest.Limiter,
|
||||
}
|
||||
@ -698,41 +691,3 @@ func hasServerSideSource(executionJob *jobs.Job) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldForceFullBackup meldet, ob dieser Lauf die Quelle vollstaendig lesen soll.
|
||||
//
|
||||
// Zwei Gruende, beide betrieblich:
|
||||
//
|
||||
// - **Immer voll.** Wer sein Backup ausser Haus gibt oder auf einen
|
||||
// Datentraeger schreibt, der einzeln weggetragen wird, will nicht, dass ein
|
||||
// Wiederherstellungspunkt an einem frueheren haengt.
|
||||
// - **Woechentlich voll.** Der uebliche Kompromiss: unter der Woche schnell,
|
||||
// an einem festen Tag einmal vollstaendig.
|
||||
//
|
||||
// Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Ohne diese
|
||||
// Umrechnung liefe derselbe Auftrag auf zwei Servern an verschiedenen Tagen
|
||||
// voll — und ein Betreiber in Berlin bekaeme seine Vollsicherung am
|
||||
// Donnerstagabend, weil der Server in UTC rechnet.
|
||||
func shouldForceFullBackup(executionJob *jobs.Job, currentTime time.Time) bool {
|
||||
if executionJob == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if executionJob.BackupMode == jobs.BackupModeAlwaysFull {
|
||||
return true
|
||||
}
|
||||
|
||||
if executionJob.FullBackupWeekday == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
scheduleLocation := time.UTC
|
||||
|
||||
if executionJob.Schedule.TimeZone != "" {
|
||||
if loadedLocation, loadError := time.LoadLocation(executionJob.Schedule.TimeZone); loadError == nil {
|
||||
scheduleLocation = loadedLocation
|
||||
}
|
||||
}
|
||||
|
||||
return currentTime.In(scheduleLocation).Weekday() == *executionJob.FullBackupWeekday
|
||||
}
|
||||
|
||||
@ -82,22 +82,6 @@ type JobSource struct {
|
||||
ExcludePatterns []string `json:"exclude_patterns,omitempty"`
|
||||
}
|
||||
|
||||
// BackupMode benennt die Sicherungsart eines Auftrags.
|
||||
type BackupMode string
|
||||
|
||||
const (
|
||||
// BackupModeIncremental sichert nach dem ersten Lauf inkrementell.
|
||||
BackupModeIncremental BackupMode = "incremental"
|
||||
// BackupModeAlwaysFull liest bei jedem Lauf die gesamte Quelle.
|
||||
//
|
||||
// Der Platzbedarf steigt dadurch **nicht** nennenswert: Unveraenderte
|
||||
// Bloecke werden dedupliziert und liegen weiterhin nur einmal im
|
||||
// Repository. Was steigt, ist die Laufzeit — jeder Lauf liest, hasht,
|
||||
// komprimiert und verschluesselt alles neu. Wer das verwechselt, plant
|
||||
// seinen Nachtbetrieb falsch.
|
||||
BackupModeAlwaysFull BackupMode = "always_full"
|
||||
)
|
||||
|
||||
// Job ist ein Sicherungsauftrag.
|
||||
type Job struct {
|
||||
// ID ist der öffentliche Bezeichner.
|
||||
@ -126,18 +110,6 @@ type Job struct {
|
||||
RecoveryTimeObjective time.Duration `json:"rto,omitempty"`
|
||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt.
|
||||
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
||||
// BackupMode bestimmt, ob nach dem ersten Lauf inkrementell gesichert wird.
|
||||
//
|
||||
// Leer bedeutet `incremental` — das bisherige Verhalten und der richtige
|
||||
// Standard: Der Gewinn ist Lesezeit, und die ist nach dem ersten Lauf der
|
||||
// begrenzende Faktor.
|
||||
BackupMode BackupMode `json:"backup_mode,omitempty"`
|
||||
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
|
||||
//
|
||||
// nil bedeutet: keiner. Gerechnet in der Zeitzone des Zeitplans; ohne
|
||||
// Angabe in UTC — sonst liefe derselbe Auftrag auf zwei Servern an
|
||||
// verschiedenen Tagen voll.
|
||||
FullBackupWeekday *time.Weekday `json:"full_backup_weekday,omitempty"`
|
||||
// MaximumConcurrency begrenzt gleichzeitige Läufe dieses Auftrags.
|
||||
MaximumConcurrency int `json:"max_concurrency"`
|
||||
// RetryPolicy beschreibt das Wiederholungsverhalten.
|
||||
|
||||
@ -72,9 +72,8 @@ func (store *PostgresStore) CreateJob(createContext context.Context, newJob *Job
|
||||
INSERT INTO backup_jobs (
|
||||
name, description, status, priority, schedule_type, schedule_config,
|
||||
repository_id, retention_policy_id, rpo_seconds, rto_seconds,
|
||||
bandwidth_limit_bps, max_concurrency, retry_policy, next_run_at, created_by,
|
||||
backup_mode, full_backup_weekday
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||
bandwidth_limit_bps, max_concurrency, retry_policy, next_run_at, created_by
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
RETURNING id`
|
||||
|
||||
var createdJobID uuid.UUID
|
||||
@ -95,8 +94,6 @@ func (store *PostgresStore) CreateJob(createContext context.Context, newJob *Job
|
||||
retryJSON,
|
||||
newJob.NextRunAt,
|
||||
newJob.CreatedBy,
|
||||
string(normalizeBackupMode(newJob.BackupMode)),
|
||||
nullableWeekday(newJob.FullBackupWeekday),
|
||||
).Scan(&createdJobID)
|
||||
|
||||
if scanError != nil {
|
||||
@ -178,7 +175,7 @@ func (store *PostgresStore) GetJob(readContext context.Context, jobIdentifier uu
|
||||
const selectJobStatement = `
|
||||
SELECT id, name, COALESCE(description,''), status, priority, schedule_config,
|
||||
repository_id, retention_policy_id, rpo_seconds, rto_seconds,
|
||||
bandwidth_limit_bps, max_concurrency, retry_policy, backup_mode, full_backup_weekday,
|
||||
bandwidth_limit_bps, max_concurrency, retry_policy,
|
||||
next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at
|
||||
FROM backup_jobs
|
||||
WHERE id = $1 AND deleted_at IS NULL`
|
||||
@ -222,10 +219,6 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
|
||||
bandwidthLimit *int64
|
||||
lastOutcomeText *string
|
||||
descriptionValue string
|
||||
// Leerer Text und NULL bedeuten beide "incremental" — der Standard,
|
||||
// der auch fuer Auftraege aus der Zeit vor dieser Spalte gilt.
|
||||
backupModeText string
|
||||
fullBackupWeekday *int16
|
||||
)
|
||||
|
||||
scanError := scanner.Scan(
|
||||
@ -242,8 +235,6 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
|
||||
&bandwidthLimit,
|
||||
&loadedJob.MaximumConcurrency,
|
||||
&retryJSON,
|
||||
&backupModeText,
|
||||
&fullBackupWeekday,
|
||||
&loadedJob.NextRunAt,
|
||||
&loadedJob.LastRunAt,
|
||||
&lastOutcomeText,
|
||||
@ -259,8 +250,6 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
|
||||
loadedJob.Description = descriptionValue
|
||||
loadedJob.Status = JobStatus(statusText)
|
||||
loadedJob.Priority = scheduler.Priority(priorityText)
|
||||
loadedJob.BackupMode = normalizeBackupMode(BackupMode(backupModeText))
|
||||
loadedJob.FullBackupWeekday = weekdayFromDatabase(fullBackupWeekday)
|
||||
|
||||
if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil {
|
||||
return nil, fmt.Errorf("der zeitplan des auftrags %s ist unlesbar: %w", loadedJob.ID, unmarshalError)
|
||||
@ -417,7 +406,7 @@ func (store *PostgresStore) ListJobs(listContext context.Context, listFilter Lis
|
||||
const selectStatement = `
|
||||
SELECT id, name, COALESCE(description,''), status, priority, schedule_config,
|
||||
repository_id, retention_policy_id, rpo_seconds, rto_seconds,
|
||||
bandwidth_limit_bps, max_concurrency, retry_policy, backup_mode, full_backup_weekday,
|
||||
bandwidth_limit_bps, max_concurrency, retry_policy,
|
||||
next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at,
|
||||
COUNT(*) OVER () AS total_count
|
||||
FROM backup_jobs
|
||||
@ -484,17 +473,12 @@ func scanJobRowWithTotal(jobRows pgx.Rows, totalCount *int) (*Job, error) {
|
||||
bandwidthLimit *int64
|
||||
lastOutcomeText *string
|
||||
descriptionValue string
|
||||
// Leerer Text und NULL bedeuten beide "incremental" — der Standard,
|
||||
// der auch fuer Auftraege aus der Zeit vor dieser Spalte gilt.
|
||||
backupModeText string
|
||||
fullBackupWeekday *int16
|
||||
)
|
||||
|
||||
scanError := jobRows.Scan(
|
||||
&loadedJob.ID, &loadedJob.Name, &descriptionValue, &statusText, &priorityText, &scheduleJSON,
|
||||
&loadedJob.RepositoryID, &loadedJob.RetentionPolicyID, &rpoSeconds, &rtoSeconds,
|
||||
&bandwidthLimit, &loadedJob.MaximumConcurrency, &retryJSON,
|
||||
&backupModeText, &fullBackupWeekday,
|
||||
&loadedJob.NextRunAt, &loadedJob.LastRunAt, &lastOutcomeText, &loadedJob.PausedAt,
|
||||
&loadedJob.CreatedBy, &loadedJob.CreatedAt, &loadedJob.UpdatedAt,
|
||||
totalCount,
|
||||
@ -506,8 +490,6 @@ func scanJobRowWithTotal(jobRows pgx.Rows, totalCount *int) (*Job, error) {
|
||||
loadedJob.Description = descriptionValue
|
||||
loadedJob.Status = JobStatus(statusText)
|
||||
loadedJob.Priority = scheduler.Priority(priorityText)
|
||||
loadedJob.BackupMode = normalizeBackupMode(BackupMode(backupModeText))
|
||||
loadedJob.FullBackupWeekday = weekdayFromDatabase(fullBackupWeekday)
|
||||
|
||||
if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil {
|
||||
return nil, unmarshalError
|
||||
@ -732,51 +714,3 @@ func defaultToEmptySlice(patternList []string) []string {
|
||||
|
||||
return patternList
|
||||
}
|
||||
|
||||
// normalizeBackupMode fuellt eine fehlende Angabe mit dem Standard.
|
||||
//
|
||||
// Leer bedeutet `incremental`. Das gilt fuer neue Auftraege ohne Angabe **und**
|
||||
// fuer alle, die vor der Migration 000014 entstanden sind — ihre Spalte traegt
|
||||
// den Vorgabewert, aber ein leerer Wert aus einem Aufrufer darf nicht zu einem
|
||||
// ungueltigen Zustand fuehren.
|
||||
func normalizeBackupMode(requestedMode BackupMode) BackupMode {
|
||||
if requestedMode == BackupModeAlwaysFull {
|
||||
return BackupModeAlwaysFull
|
||||
}
|
||||
|
||||
return BackupModeIncremental
|
||||
}
|
||||
|
||||
// nullableWeekday uebersetzt einen Wochentag fuer die Datenbank.
|
||||
//
|
||||
// nil bedeutet: kein erzwungener Volltag. Ein Wochentag ausserhalb von 0..6
|
||||
// wird verworfen statt gekappt — ein stillschweigend auf Sonntag gesetzter
|
||||
// Montag waere ein Fehler, den niemand bemerkt.
|
||||
func nullableWeekday(requestedWeekday *time.Weekday) *int16 {
|
||||
if requestedWeekday == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if *requestedWeekday < time.Sunday || *requestedWeekday > time.Saturday {
|
||||
return nil
|
||||
}
|
||||
|
||||
storedValue := int16(*requestedWeekday)
|
||||
|
||||
return &storedValue
|
||||
}
|
||||
|
||||
// weekdayFromDatabase uebersetzt einen gespeicherten Wochentag zurueck.
|
||||
func weekdayFromDatabase(storedValue *int16) *time.Weekday {
|
||||
if storedValue == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if *storedValue < 0 || *storedValue > 6 {
|
||||
return nil
|
||||
}
|
||||
|
||||
loadedWeekday := time.Weekday(*storedValue)
|
||||
|
||||
return &loadedWeekday
|
||||
}
|
||||
|
||||
@ -40,28 +40,6 @@ readonly defaultRepositoryPath="/srv/syncova-repository"
|
||||
# backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf.
|
||||
readonly backupDirectory="/var/backups/syncova"
|
||||
|
||||
# additionalRestoreRoots sind weitere Ziele fuer Wiederherstellungen.
|
||||
#
|
||||
# Wer nach /srv/wiederherstellung oder auf eine Freigabe zurueckschreiben will,
|
||||
# nennt sie beim Einrichten mit --wiederherstellungsziel. Sie landen in
|
||||
# ReadWritePaths der Einheit; ohne diesen Eintrag nuetzen die Rechte des
|
||||
# Verzeichnisses nichts.
|
||||
additionalRestoreRoots=""
|
||||
|
||||
# restoreDirectory ist die Flaeche, auf die zurueckgeschrieben werden darf.
|
||||
#
|
||||
# Sie ist noetig, weil der Dienst mit ProtectSystem=strict laeuft: Ausserhalb
|
||||
# der in ReadWritePaths genannten Pfade ist das Dateisystem fuer ihn
|
||||
# schreibgeschuetzt. Ohne eine solche Flaeche endet **jede** Wiederherstellung
|
||||
# mit "mkdir: permission denied" — und zwar erst nach der Vorabpruefung, also
|
||||
# an der unangenehmsten Stelle.
|
||||
#
|
||||
# Der Ort liegt unter /srv und nicht unter /var/lib: Letzteres steht auf der
|
||||
# Sperrliste des Zielschutzes (packages/recovery/targetguard.go), weil eine
|
||||
# Wiederherstellung dorthin den Zustand der Anlage selbst ueberschreiben
|
||||
# koennte. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig.
|
||||
readonly restoreDirectory="/srv/syncova-restore"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ausgabe
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -234,10 +212,6 @@ while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--paket) packageDirectory="${2:-}"; shift 2 ;;
|
||||
--repository) repositoryPath="${2:-}"; shift 2 ;;
|
||||
--wiederherstellungsziel)
|
||||
# Mehrfach angebbar. Die Pfade kommen in ReadWritePaths der Einheit;
|
||||
# ohne diesen Eintrag nuetzen die Rechte des Verzeichnisses nichts.
|
||||
additionalRestoreRoots="${additionalRestoreRoots} ${2:-}"; shift 2 ;;
|
||||
--adresse) listenAddress="${2:-}"; shift 2 ;;
|
||||
--admin) administratorName="${2:-}"; shift 2 ;;
|
||||
--ohne-haertung) createHardenedRepository="nein"; shift ;;
|
||||
@ -1073,11 +1047,6 @@ noteCreated "directory:${configurationDirectory}"
|
||||
|
||||
install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${backupDirectory}"
|
||||
|
||||
# Die Wiederherstellungsflaeche. Ohne sie scheitert jede Wiederherstellung an
|
||||
# ProtectSystem=strict — und der Betreiber sucht den Fehler bei den Rechten des
|
||||
# Zielverzeichnisses statt bei der Haertung des Dienstes.
|
||||
install -d -m 0750 -o "${serviceAccount}" -g "${serviceAccount}" "${restoreDirectory}"
|
||||
|
||||
cp -R "${packageDirectory}/bin" "${installationRoot}/"
|
||||
[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/"
|
||||
[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/"
|
||||
@ -1327,7 +1296,7 @@ NoNewPrivileges=yes
|
||||
PrivateTmp=${privateTmpSetting}
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=${repositoryPath} ${backupDirectory} ${restoreDirectory}${additionalRestoreRoots}
|
||||
ReadWritePaths=${repositoryPath} ${backupDirectory}
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
|
||||
@ -30,16 +30,8 @@ readonly installationRoot="/opt/syncova"
|
||||
readonly configurationDirectory="/etc/syncova"
|
||||
readonly configurationFile="${configurationDirectory}/syncova.env"
|
||||
readonly serviceName="syncova-api"
|
||||
readonly serviceAccount="syncova"
|
||||
readonly backupDirectory="/var/backups/syncova"
|
||||
|
||||
# restoreDirectory ist die Flaeche, auf die zurueckgeschrieben werden darf.
|
||||
#
|
||||
# setup.sh legt sie an; bei einer Anlage aus einer aelteren Fassung fehlt sie.
|
||||
# Ohne sie scheitert jede Wiederherstellung an ProtectSystem=strict — und der
|
||||
# Betreiber sucht den Fehler bei den Rechten des Zielverzeichnisses.
|
||||
readonly restoreDirectory="/srv/syncova-restore"
|
||||
|
||||
# previousReleaseRoot nimmt die abgeloeste Fassung auf.
|
||||
#
|
||||
# Sie bleibt liegen, bis die neue nachweislich laeuft. Ein Update, das die alte
|
||||
@ -427,41 +419,6 @@ fi
|
||||
|
||||
writeSuccess "Schema aktuell"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6b. Wiederherstellungsflaeche nachruesten
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Sie kam erst mit rc8 dazu. Eine Anlage aus einer aelteren Fassung hat sie
|
||||
# nicht, und ohne sie schlaegt jede Wiederherstellung fehl. Das Nachruesten
|
||||
# gehoert hierher und nicht in eine Anleitung: Ein Schritt, den man von Hand
|
||||
# ausfuehren muss, wird uebersehen — und faellt erst im Ernstfall auf.
|
||||
|
||||
printf '\n'
|
||||
writeStep "Wiederherstellungsflaeche"
|
||||
|
||||
if [[ -d "${restoreDirectory}" ]]; then
|
||||
writeDetail "Vorhanden: ${restoreDirectory}"
|
||||
else
|
||||
install -d -m 0750 -o "${serviceAccount}" -g "${serviceAccount}" "${restoreDirectory}"
|
||||
writeSuccess "Angelegt: ${restoreDirectory}"
|
||||
fi
|
||||
|
||||
# Der Eintrag in der Einheit ist der eigentliche Punkt. Die Rechte des
|
||||
# Verzeichnisses nuetzen nichts, solange ProtectSystem=strict den Pfad nicht
|
||||
# freigibt.
|
||||
unitFile="/etc/systemd/system/${serviceName}.service"
|
||||
|
||||
if [[ -f "${unitFile}" ]] && ! grep -q "${restoreDirectory}" "${unitFile}"; then
|
||||
if grep -q "^ReadWritePaths=" "${unitFile}"; then
|
||||
sed -i "s|^ReadWritePaths=.*|& ${restoreDirectory}|" "${unitFile}"
|
||||
writeSuccess "In ReadWritePaths eingetragen"
|
||||
else
|
||||
writeWarning "Die Einheit hat kein ReadWritePaths — bitte von Hand pruefen."
|
||||
fi
|
||||
else
|
||||
writeDetail "ReadWritePaths ist bereits eingetragen."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Starten und pruefen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user