Compare commits

..

4 Commits

Author SHA1 Message Date
d94debac4d Dokumentation und Aenderungsliste fuer rc9
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 27s
Die Sicherungsart steht in backup-engine.md, weil dort der Unterschied zwischen
voll und inkrementell erklaert ist — mit der Einordnung, die am haeufigsten
verwechselt wird: Der Platzbedarf steigt bei "immer voll" nicht nennenswert,
die Laufzeit schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:06 +02:00
8e98cc7510 Sicherungsart je Auftrag, Agenten-Token und -Anleitung, update.sh
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
**Sicherungsart.** Bisher entschied der Executor allein: Liegt ein Elternbackup
vor, wird inkrementell gesichert. Jetzt waehlbar je Auftrag —

- `incremental` (Standard, bisheriges Verhalten),
- `always_full`, oder
- inkrementell **mit einem festen Volltag** ("immer freitags").

Migration 000014 mit drei CHECKs. Der dritte lehnt "immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll, und die Regel gehoert in
die Datenbank, weil im Code jede Stelle sie einhalten muesste — eine vergisst
es. Real geprueft: der Widerspruch wird abgewiesen.

Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt. Vier Tests, der
entscheidende durch Mutation als fangend bestaetigt.

Zur Einordnung, weil es leicht verwechselt wird: Der Platzbedarf steigt bei
"immer voll" **nicht** nennenswert — unveraenderte Bloecke werden dedupliziert
und liegen weiterhin nur einmal im Repository. Was steigt, ist die Laufzeit.
Steht so in der Maske.

**Aufnahme-Token zeigte "undefined".** Das Feld heisst `token`, nicht
`enrollment_token` — Letzteres ist der Name im *Anfrage*koerper der
Registrierung. Der dritte Formfehler dieser Art; alle konsumierten Endpunkte
sind jetzt gegen den laufenden Dienst abgeglichen.

**Der Aufnahmedialog** hat jetzt eine vollstaendige Anleitung fuer Linux und
Windows mit fertig ausgefuellten Befehlen — Serveradresse und Token eingesetzt,
je Schritt einzeln kopierbar. Eine Anleitung mit Platzhaltern fuehrt
zuverlaessig dazu, dass jemand `<token>` woertlich einsetzt und dann eine
Fehlermeldung sucht, die nichts mit seinem Problem zu tun hat. Dazu die beiden
Stolperstellen: `--state` will eine Datei, und der Agent braucht Schreibzugriff
aufs Repository. Beim Windows-Weg steht dabei, dass der Dienst nie auf echter
Hardware lief.

**update.sh ruestet die Wiederherstellungsflaeche nach** — anlegen und in
ReadWritePaths eintragen. Ein Schritt, den man von Hand ausfuehren muss, wird
uebersehen und faellt erst im Ernstfall auf.

84 Tests im Frontend, alle Go-Tests gruen, shellcheck sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:13:22 +02:00
b78a6fb51c Dokumentation und Aenderungsliste fuer rc8
Some checks failed
CI / Backend (Go) (push) Failing after 32s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
Der Abschnitt "Wohin darf zurueckgeschrieben werden?" im Runbook ist der
wichtigste Zusatz: Dass ein Ziel an ProtectSystem=strict scheitert und nicht an
den Rechten des Verzeichnisses, sieht man dem Fehler nicht an. Die Tabelle nennt
die vier Faelle samt Grund.

Die Beispiel-Einheit in der Installationsanleitung fuehrte in denselben Fehler —
sie nannte nur das Repository in ReadWritePaths. Eine Anleitung, deren
Ergebnis keine Wiederherstellung zulaesst, ist schlimmer als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:55:52 +02:00
20b0919676 Datei- und Ordnerwiederherstellung, Ordnerbaum, Geist Mono im Paket
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 45s
CI / Sicherheitsprüfungen (push) Successful in 27s
**Warum keine Wiederherstellung funktionierte.** Der Dienst laeuft mit
`ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
Sicherungsordner — alles andere ist fuer ihn schreibgeschuetzt. Jedes Ziel
ausserhalb endete mit "mkdir: permission denied", und zwar **nach** der
Vorabpruefung. `/tmp` scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein
eigenes /tmp, und was dort landet, sieht man von aussen gar nicht.

`setup.sh` legt jetzt `/srv/syncova-restore` an und traegt es in
`ReadWritePaths` ein; `--wiederherstellungsziel` ergaenzt weitere. Der Ort liegt
unter /srv und nicht unter /var/lib — Letzteres steht auf der Sperrliste des
Zielschutzes. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig; das
ist mir erst aufgefallen, nachdem ich die Flaeche zunaechst falsch gelegt hatte
und der eigene Zielschutz sie ablehnte.

**Zwei neue Endpunkte** (Vertrag entsprechend erweitert):

- `GET /filesystem/browse` — Verzeichnisse mit der Angabe, ob der **Dienst**
  dort schreiben darf. **Gemessen** durch eine Probedatei, nicht aus den
  Rechtebits geraten: Unter ProtectSystem=strict sagen die Bits nichts ueber
  das aus, was der Namensraum zulaesst. Gesperrte Orte werden gezeigt, nicht
  versteckt — sonst bliebe offen, warum ein Pfad fehlt.
- `GET /backups/{id}/contents` — das Manifest als Ebene eines Baums. Der Baum
  entsteht aus den **Pfaden**, nicht aus Verzeichniseintraegen: Ein Manifest
  kann eine Datei enthalten, deren Elternverzeichnis nicht als eigener Eintrag
  vorliegt, und wer nur `directory`-Eintraege auflistet, verliert ganze
  Teilbaeume. Durch Mutation bestaetigt.

**Auswahl statt Textfeld.** Der Assistent hat jetzt einen Ordnerbaum fuer das
Ziel und einen Browser fuer den Backup-Inhalt. Ordner **und** einzelne Dateien
lassen sich waehlen; beides geht als `path_prefix` in die Anfrage, weil der
Server auf Gleichheit oder Praefix mit Verzeichnisgrenze vergleicht. Bewusste
Grenze: eine Auswahl je Lauf — eine Liste kennt die API nicht, und mehrere
Laeufe vorzutaeuschen ergaebe mehrere Ausgaenge, die niemand mehr erklaeren
kann.

**Integritaetslauf.** "can't access property toLocaleString, chunks_checked is
undefined" — die Ergebnisse liegen unter `details`, und die Felder heissen
`missing_chunks`/`corrupted_chunks`, nicht umgekehrt. Betrifft alle vier
Pruefendpunkte; sie tragen dieselbe Huelle. Derselbe Fehler wie bei
/retention-policies: die Antwortform angenommen statt geprueft.

**Geist Mono liegt jetzt im Paket** (drei Schnitte, 128 KB, OFL-Lizenz dabei).
Ausgeliefert vom eigenen Ursprung — das verlangt die CSP, und ein
Backup-Server, dessen Oberflaeche von einem CDN abhaengt, waere auch ohne CSP
falsch. `font-display: swap`, damit der Text sofort steht.

Nachgewiesen gegen Debian 12: Vollwiederherstellung (5 Dateien), nur ein Ordner
(2 Dateien), nur eine Datei (1 Datei) — alle drei bitgenau. Der Server nennt
/srv/syncova-restore als beschreibbar und /etc, /usr, /var als gesperrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:52:37 +02:00
38 changed files with 2211 additions and 145 deletions

View File

@ -1,5 +1,106 @@
# Änderungen # Ä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 ## V1 — Release Candidate 7, 18. August 2026
Behebt einen Absturz, macht die Sitzung brauchbar und stellt das Aussehen um. Behebt einen Absturz, macht die Sitzung brauchbar und stellt das Aussehen um.

View File

@ -0,0 +1,422 @@
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
}

View File

@ -0,0 +1,90 @@
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)
}
}

View File

@ -122,3 +122,5 @@ POST /api/v1/users users.write
POST /api/v1/users/{id}/mfa/disable users.write POST /api/v1/users/{id}/mfa/disable users.write
POST /api/v1/verification verification.write POST /api/v1/verification verification.write
POST /api/v1/verification/{id}/cancel 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

View File

@ -89,6 +89,14 @@ type jobRequest struct {
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz. // BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` 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 begrenzt gleichzeitige Läufe.
MaximumConcurrency int `json:"max_concurrency,omitempty"` MaximumConcurrency int `json:"max_concurrency,omitempty"`
} }
@ -127,6 +135,14 @@ type jobResponse struct {
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz. // BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` 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 begrenzt gleichzeitige Läufe.
MaximumConcurrency int `json:"max_concurrency"` MaximumConcurrency int `json:"max_concurrency"`
// NextRunAt ist der nächste Zeitpunkt in UTC. // NextRunAt ist der nächste Zeitpunkt in UTC.

View File

@ -57,6 +57,8 @@ func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job,
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second, RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second, RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond, BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
BackupMode: jobs.BackupMode(jobPayload.BackupMode),
FullBackupWeekday: weekdayFromPayload(jobPayload.FullBackupWeekday),
MaximumConcurrency: maximumConcurrency, MaximumConcurrency: maximumConcurrency,
RetryPolicy: scheduler.DefaultRetryPolicy(), RetryPolicy: scheduler.DefaultRetryPolicy(),
CreatedBy: &creatorID, CreatedBy: &creatorID,
@ -152,6 +154,8 @@ func buildJobResponse(sourceJob *jobs.Job) jobResponse {
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()), RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()), RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond, BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
BackupMode: string(sourceJob.BackupMode),
FullBackupWeekday: weekdayToPayload(sourceJob.FullBackupWeekday),
MaximumConcurrency: sourceJob.MaximumConcurrency, MaximumConcurrency: sourceJob.MaximumConcurrency,
NextRunAt: sourceJob.NextRunAt, NextRunAt: sourceJob.NextRunAt,
LastRunAt: sourceJob.LastRunAt, LastRunAt: sourceJob.LastRunAt,
@ -190,3 +194,33 @@ func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest {
return scheduleData 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
}

View File

@ -377,6 +377,19 @@ func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore)) 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}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore)) 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). // registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,92 @@
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.

View File

@ -10,7 +10,7 @@
* könnte er sich als ein anderes System ausgeben. * könnte er sich als ein anderes System ausgeben.
*/ */
import { Ban, Copy, KeyRound, Plus, RefreshCw } from 'lucide-react'; import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } from 'lucide-react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource'; import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation'; import { describeApiError, useMutation } from '@/api/useMutation';
@ -32,7 +32,7 @@ import {
useToast, useToast,
type TableColumn, type TableColumn,
} from '@/components/ui'; } from '@/components/ui';
import { formatRelativeTime } from '@/lib/utils'; import { formatDateTime, formatRelativeTime } from '@/lib/utils';
import { import {
createEnrollmentToken, createEnrollmentToken,
listAgents, listAgents,
@ -300,6 +300,17 @@ export function AgentsPage({
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine * Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
* Schaltfläche, und sie sagt, was sie bewirkt. * 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({ function IssuedTokenDialog({
token, token,
onClose, onClose,
@ -308,64 +319,199 @@ function IssuedTokenDialog({
readonly onClose: () => void; readonly onClose: () => void;
}) { }) {
const toast = useToast(); const toast = useToast();
const [hasCopied, setHasCopied] = useState(false); const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
const copyToken = async () => { // 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) => {
try { try {
await navigator.clipboard.writeText(token.enrollment_token); await navigator.clipboard.writeText(textToCopy);
setHasCopied(true); setCopiedKey(entryKey);
window.setTimeout(() => setHasCopied(false), 2000); window.setTimeout(() => setCopiedKey(null), 2000);
} catch { } catch {
toast.showInfo( toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.');
'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 ( return (
<DialogRoot open onOpenChange={() => undefined}> <DialogRoot open onOpenChange={() => undefined}>
<DialogContent size="lg"> <DialogContent size="xl">
<DialogHeader <DialogHeader
title="Aufnahme-Token" title="Agent aufnehmen"
description={`Für den Agenten „${token.agent_name}“`} description={`Für „${token.agent_name}"`}
/> />
<DialogBody> <DialogBody>
<Callout tone="warning" title="Dieses Token erscheint genau einmal"> <Callout tone="warning" title="Dieses Token erscheint genau einmal">
Es wird nur als Hash gespeichert und lässt sich nicht wieder Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst,
abrufen. Schließen Sie dieses Fenster erst, wenn Sie es sicher wenn der Agent aufgenommen ist.
hinterlegt haben — sonst müssen Sie ein neues erzeugen.
</Callout> </Callout>
<div className="rounded-md border border-line bg-sunken p-3"> <div className="border border-line bg-sunken p-3">
<code className="block break-all font-mono text-sm text-fg"> <div className="flex items-start justify-between gap-3">
{token.enrollment_token} <code className="min-w-0 break-all font-mono text-sm text-fg">
</code> {token.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.
</p>
) : null}
</div> </div>
<Button onClick={() => void copyToken()}> {/* Systemwahl */}
{hasCopied ? <KeyRound /> : <Copy />} <div className="flex gap-2 border-b border-line pb-3">
{hasCopied ? 'Kopiert' : 'Token kopieren'} <Button
</Button> size="sm"
variant={platform === 'linux' ? 'primary' : 'ghost'}
<div className="border-t border-line pt-4"> onClick={() => setPlatform('linux')}
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle"> >
Auf dem zu sichernden System <Terminal />
</p> Linux
<code className="mt-1.5 block overflow-x-auto rounded bg-sunken px-2 py-2 font-mono text-xs text-fg"> </Button>
syncova-agent enroll --server https://&lt;dieser-server&gt; --token <Button
&lt;token&gt; --state /var/lib/syncova-agent/state.json size="sm"
</code> variant={platform === 'windows' ? 'primary' : 'ghost'}
<p className="mt-1.5 text-xs text-fg-muted"> onClick={() => setPlatform('windows')}
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis. >
Mit einem Verzeichnis hält sich der Agent für registriert und <MonitorCog />
läuft ohne Token. Windows (PowerShell)
</p> </Button>
</div> </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> </DialogBody>
<DialogFooter> <DialogFooter>
<Button variant="primary" onClick={onClose}> <Button variant="primary" onClick={onClose}>
Ich habe das Token hinterlegt Der Agent ist aufgenommen
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -197,15 +197,22 @@ export interface Agent {
enrolled_at?: string; enrolled_at?: string;
} }
/** Antwort auf die Erzeugung eines Aufnahme-Tokens. */ /**
* 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.
*/
export interface EnrollmentToken { export interface EnrollmentToken {
id: string;
/** /**
* Das Token im Klartext — **einmalig**. * Das Token im Klartext — **einmalig**.
* *
* Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen. Die * Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen.
* Oberfläche muss das sagen, sonst schließt jemand das Fenster.
*/ */
enrollment_token: string; token: string;
agent_name: string; agent_name: string;
expires_at?: string; expires_at?: string;
} }

View File

@ -9,7 +9,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { createJob, listRepositories } from './jobsApi'; import { createJob, listRepositories, WEEKDAY_LABELS } from './jobsApi';
import type { BackupJob, BackupRepository, SourceType } from './jobsApi'; import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
import { import {
buildCreateRequest, buildCreateRequest,
@ -516,6 +516,71 @@ 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> <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> </div>
); );
} }

View File

@ -45,6 +45,7 @@ import {
pauseJob, pauseJob,
resumeJob, resumeJob,
runJob, runJob,
WEEKDAY_LABELS,
type BackupJobRun, type BackupJobRun,
} from './jobsApi'; } from './jobsApi';
@ -276,6 +277,15 @@ export function JobDetailPage({
: 'Ohne Grenze'} : 'Ohne Grenze'}
</DetailItem> </DetailItem>
<DetailItem label="Quellen">{job.sources.length}</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> </dl>
<div className="mt-5 border-t border-line pt-4"> <div className="mt-5 border-t border-line pt-4">

View File

@ -47,6 +47,7 @@ import {
pauseJob, pauseJob,
resumeJob, resumeJob,
runJob, runJob,
WEEKDAY_LABELS,
type BackupJob, type BackupJob,
} from './jobsApi'; } from './jobsApi';
@ -133,7 +134,14 @@ export function JobsPage({
render: (job) => ( render: (job) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-fg">{job.name}</p> <p className="truncate font-medium text-fg">{job.name}</p>
<p className="truncate text-xs text-fg-muted">{job.schedule_description}</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>
</div> </div>
), ),
}, },

View File

@ -82,8 +82,35 @@ export interface CreateJobRequest {
rto_seconds?: number; rto_seconds?: number;
/** Bandbreitengrenze in Byte je Sekunde. */ /** Bandbreitengrenze in Byte je Sekunde. */
bandwidth_limit_bps?: number; 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. */ /** Auftrag in der Antwort der API. */
export interface BackupJob { export interface BackupJob {
/** Öffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
@ -112,6 +139,10 @@ export interface BackupJob {
last_outcome?: string; last_outcome?: string;
/** Bandbreitengrenze in Byte je Sekunde. */ /** Bandbreitengrenze in Byte je Sekunde. */
bandwidth_limit_bps?: number; bandwidth_limit_bps?: number;
/** Sicherungsart. */
backup_mode?: BackupMode;
/** Wochentag einer erzwungenen Vollsicherung. */
full_backup_weekday?: number;
} }
/** Sicherungsziel in der Antwort der API. */ /** Sicherungsziel in der Antwort der API. */

View File

@ -36,7 +36,7 @@ import {
useToast, useToast,
type TableColumn, type TableColumn,
} from '@/components/ui'; } from '@/components/ui';
import { formatBytes, formatDateTime, formatDuration } from '@/lib/utils'; import { formatBytes, formatDateTime } from '@/lib/utils';
import { import {
adoptRepository, adoptRepository,
checkRepositoryHealth, checkRepositoryHealth,
@ -44,7 +44,7 @@ import {
measureEnforcement, measureEnforcement,
rebuildCatalog, rebuildCatalog,
startIntegrityScan, startIntegrityScan,
type IntegrityScanResult, type IntegrityScanDetails,
type Repository, type Repository,
} from './repositoriesApi'; } from './repositoriesApi';
@ -56,7 +56,7 @@ export function RepositoriesPage({
const toast = useToast(); const toast = useToast();
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false); const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null); const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null);
const [scanResult, setScanResult] = useState<IntegrityScanResult | null>(null); const [scanResult, setScanResult] = useState<IntegrityScanDetails | null>(null);
const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null); const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null);
const [adoptName, setAdoptName] = useState(''); const [adoptName, setAdoptName] = useState('');
@ -96,10 +96,10 @@ export function RepositoriesPage({
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung // Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt, // unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
// wird bald nicht mehr ernst genommen. // wird bald nicht mehr ernst genommen.
if (result.chunks_missing > 0 || result.chunks_corrupted > 0) { if (result.missing_chunks > 0 || result.corrupted_chunks > 0) {
toast.showError( toast.showError(
'Der Integritätslauf hat Befunde', 'Der Integritätslauf hat Befunde',
`${result.chunks_missing} Blöcke fehlen, ${result.chunks_corrupted} sind beschädigt.`, `${result.missing_chunks} Blöcke fehlen, ${result.corrupted_chunks} sind beschädigt.`,
); );
} else { } else {
toast.showSuccess( toast.showSuccess(
@ -119,11 +119,21 @@ export function RepositoriesPage({
}); });
const healthMutation = useMutation(checkRepositoryHealth, { const healthMutation = useMutation(checkRepositoryHealth, {
onSuccess: (health) => { onSuccess: (checkResponse) => {
toast.showSuccess( // Erreichbarkeit und Befund sind zwei Aussagen. Die erste steht in der
'Gesundheitsprüfung abgeschlossen', // Hülle, die zweite in `details`.
health.message ?? `Zustand: ${health.status}`, if (!checkResponse.reachable) {
); toast.showError(
'Das Repository ist nicht erreichbar',
checkResponse.error ?? 'Ohne nähere Angabe.',
);
} else {
toast.showSuccess(
'Gesundheitsprüfung abgeschlossen',
checkResponse.details?.message ?? 'Das Repository ist erreichbar.',
);
}
repositoriesResource.reload(); repositoriesResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
@ -143,11 +153,10 @@ export function RepositoriesPage({
}); });
const rebuildMutation = useMutation(rebuildCatalog, { const rebuildMutation = useMutation(rebuildCatalog, {
onSuccess: (result) => { onSuccess: (checkResponse) => {
toast.showSuccess( toast.showSuccess(
'Katalog neu aufgebaut', 'Katalog neu aufgebaut',
result.summary ?? `${checkResponse.details?.backups_in_catalog ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
`${result.backups_found ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
); );
repositoriesResource.reload(); repositoriesResource.reload();
}, },
@ -410,10 +419,10 @@ function IntegrityScanCard({
result, result,
onClose, onClose,
}: { }: {
readonly result: IntegrityScanResult; readonly result: IntegrityScanDetails;
readonly onClose: () => void; readonly onClose: () => void;
}) { }) {
const hasFindings = result.chunks_missing > 0 || result.chunks_corrupted > 0; const hasFindings = result.missing_chunks > 0 || result.corrupted_chunks > 0;
return ( return (
<Card className="mt-4"> <Card className="mt-4">
@ -429,7 +438,7 @@ function IntegrityScanCard({
<Callout tone={hasFindings ? 'critical' : 'healthy'}> <Callout tone={hasFindings ? 'critical' : 'healthy'}>
{hasFindings {hasFindings
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.' ? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
: 'Ohne Befund. Jeder geprüfte Block stimmt mit seiner Prüfsumme überein.'} : result.summary}
</Callout> </Callout>
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
@ -437,27 +446,36 @@ function IntegrityScanCard({
{result.chunks_checked.toLocaleString('de-DE')} {result.chunks_checked.toLocaleString('de-DE')}
</DetailItem> </DetailItem>
<DetailItem label="Fehlend"> <DetailItem label="Fehlend">
<span className={result.chunks_missing > 0 ? 'text-critical' : undefined}> <span className={result.missing_chunks > 0 ? 'text-critical' : undefined}>
{result.chunks_missing.toLocaleString('de-DE')} {result.missing_chunks.toLocaleString('de-DE')}
</span> </span>
</DetailItem> </DetailItem>
<DetailItem label="Beschädigt"> <DetailItem label="Beschädigt">
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}> <span className={result.corrupted_chunks > 0 ? 'text-critical' : undefined}>
{result.chunks_corrupted.toLocaleString('de-DE')} {result.corrupted_chunks.toLocaleString('de-DE')}
</span> </span>
</DetailItem> </DetailItem>
<DetailItem label="Dauer">{formatDuration(result.duration_seconds)}</DetailItem> <DetailItem label="Backups">
{result.backups_healthy} von {result.backups_checked} vollständig
</DetailItem>
</dl> </dl>
{result.findings && result.findings.length > 0 ? ( {!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 ? (
<div className="mt-4"> <div className="mt-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle"> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Betroffene Objekte Betroffene Backups
</p> </p>
<ul className="mt-1.5 space-y-1"> <ul className="mt-1.5 space-y-1">
{result.findings.map((finding) => ( {result.affected_backup_ids.map((backupIdentifier) => (
<li key={finding} className="break-all font-mono text-xs text-fg-muted"> <li key={backupIdentifier} className="break-all font-mono text-xs text-fg-muted">
{finding} {backupIdentifier}
</li> </li>
))} ))}
</ul> </ul>

View File

@ -32,21 +32,49 @@ export interface Repository {
created_at?: string; created_at?: string;
} }
/** Ergebnis eines Integritätslaufs. */ /**
export interface IntegrityScanResult { * Antworthülle der Repository-Prüfungen.
/** Geprüft. */ *
* **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. */
chunks_checked: number; chunks_checked: number;
/** Nicht auffindbar. */ /** Nicht auffindbar — jeder einzelne verhindert eine Wiederherstellung. */
chunks_missing: number; missing_chunks: number;
/** Prüfsumme stimmt nicht. */ /** Prüfsumme stimmt nicht. */
chunks_corrupted: number; corrupted_chunks: number;
manifests_checked?: number; /** Blöcke ohne Verweis aus einem Manifest. */
manifests_invalid?: number; orphaned_chunks: number;
duration_seconds?: number; backups_checked: number;
/** Zusammenfassung im Klartext. */ backups_healthy: number;
summary?: string; /** Meldet, ob die Blockinhalte gelesen wurden oder nur die Kennungen. */
/** Betroffene Objekte, sofern benennbar. */ verified_chunk_contents: boolean;
findings?: string[]; healthy: boolean;
summary: string;
/** Betroffene Backups, sofern benennbar. */
affected_backup_ids?: string[] | null;
} }
/** Ergebnis einer Gesundheitsprüfung. */ /** Ergebnis einer Gesundheitsprüfung. */
@ -126,8 +154,8 @@ export async function updateRepository(
/** Prüft die Erreichbarkeit. */ /** Prüft die Erreichbarkeit. */
export async function testRepository( export async function testRepository(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<RepositoryHealth> { ): Promise<RepositoryCheckResponse<Record<string, unknown>>> {
return requestApi<RepositoryHealth>( return requestApi<RepositoryCheckResponse<Record<string, unknown>>>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`, `/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
{ method: 'POST' }, { method: 'POST' },
); );
@ -136,8 +164,8 @@ export async function testRepository(
/** Führt eine Gesundheitsprüfung aus. */ /** Führt eine Gesundheitsprüfung aus. */
export async function checkRepositoryHealth( export async function checkRepositoryHealth(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<RepositoryHealth> { ): Promise<RepositoryCheckResponse<RepositoryHealth>> {
return requestApi<RepositoryHealth>( return requestApi<RepositoryCheckResponse<RepositoryHealth>>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`, `/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
{ method: 'POST' }, { method: 'POST' },
); );
@ -152,11 +180,21 @@ export async function checkRepositoryHealth(
*/ */
export async function startIntegrityScan( export async function startIntegrityScan(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<IntegrityScanResult> { ): Promise<IntegrityScanDetails> {
return requestApi<IntegrityScanResult>( const response = await requestApi<RepositoryCheckResponse<IntegrityScanDetails>>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`, `/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
{ method: 'POST', idempotencyKey: true }, { 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;
} }
/** /**
@ -168,8 +206,8 @@ export async function startIntegrityScan(
*/ */
export async function rebuildCatalog( export async function rebuildCatalog(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<{ backups_found?: number; summary?: string }> { ): Promise<RepositoryCheckResponse<{ backups_in_catalog?: number }>> {
return requestApi<{ backups_found?: number; summary?: string }>( return requestApi<RepositoryCheckResponse<{ backups_in_catalog?: number }>>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`, `/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
{ method: 'POST', idempotencyKey: true }, { method: 'POST', idempotencyKey: true },
); );

View File

@ -0,0 +1,189 @@
/**
* 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>
);
}

View File

@ -0,0 +1,220 @@
/**
* 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>
);
}

View File

@ -41,6 +41,8 @@ import {
useToast, useToast,
} from '@/components/ui'; } from '@/components/ui';
import { cn, formatBytes, formatDuration } from '@/lib/utils'; import { cn, formatBytes, formatDuration } from '@/lib/utils';
import { BackupContentPicker } from './BackupContentPicker';
import { DirectoryPicker } from './DirectoryPicker';
import { import {
createRestore, createRestore,
validateRestore, validateRestore,
@ -170,35 +172,15 @@ export function RestoreWizard({
<DialogBody className="min-h-64"> <DialogBody className="min-h-64">
{currentStepIndex === 0 ? ( {currentStepIndex === 0 ? (
<div className="space-y-4"> <DirectoryPicker selectedPath={targetPath} onSelect={setTargetPath} />
<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} ) : null}
{currentStepIndex === 1 ? ( {currentStepIndex === 1 ? (
<div className="space-y-4"> <div className="space-y-4">
<TextInput <BackupContentPicker
label="Nur ein Teilbaum (optional)" backupIdentifier={backupIdentifier}
placeholder="daten/projekte" selectedPath={pathPrefix}
value={pathPrefix} onSelect={setPathPrefix}
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"> <div className="space-y-3 border-t border-line pt-4">
@ -209,8 +191,8 @@ export function RestoreWizard({
label="Vorhandene Dateien überschreiben" label="Vorhandene Dateien überschreiben"
hint={ hint={
mayOverwrite mayOverwrite
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusätzlich verlangt der Server danach den wörtlich wiederholten Zielpfad.' ? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt.'
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.' : 'Ihrer Rolle fehlt das Recht restores.overwrite.'
} }
/> />
@ -218,24 +200,16 @@ export function RestoreWizard({
checked={skipPermissions} checked={skipPermissions}
onCheckedChange={setSkipPermissions} onCheckedChange={setSkipPermissions}
label="Rechte nicht zurückschreiben" label="Rechte nicht zurückschreiben"
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten." hint="Dateien entstehen mit den Standardrechten des Dienstkontos."
/> />
<CheckboxField <CheckboxField
checked={skipDeepCheck} checked={skipDeepCheck}
onCheckedChange={setSkipDeepCheck} onCheckedChange={setSkipDeepCheck}
label="Blockprüfung überspringen" label="Blockprüfung überspringen"
hint="Beschleunigt die Vorabprüfung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benötigte Block noch vorhanden ist." hint="Schneller, aber dann ist nicht belegt, dass die Daten noch da sind."
/> />
</div> </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> </div>
) : null} ) : null}

View File

@ -0,0 +1,82 @@
/**
* 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 } : {},
);
}

View File

@ -15,11 +15,10 @@
* 2. **Das dunkle Thema hängt an `[data-theme='dark']`, nicht an `.dark`.** Der * 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 * Umschalter der Konsole setzt dieses Attribut, und er ist getestet. Beide
* Schreibweisen werden unterstützt. * Schreibweisen werden unterstützt.
* 3. **Die Schrift lädt nicht nach.** Geist Mono steht zuerst im Stapel und * 3. **Geist Mono liegt im Paket.** Sie wird vom eigenen Ursprung ausgeliefert,
* wird verwendet, wenn sie auf dem Gerät liegt; sonst greift die * nicht von einem CDN: Das verlangt die Content-Security-Policy, und ein
* System-Monospace. Eine Schrift von einem fremden Host zu holen verbietet * Backup-Server, dessen Oberfläche von der Erreichbarkeit eines fremden
* die Content-Security-Policy der Auslieferung — und ein Backup-Server, der * Hosts abhängt, wäre auch ohne CSP falsch.
* für seine Oberfläche ins Internet greift, wäre auch ohne CSP falsch.
* *
* Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`) * Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`)
* statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets * statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets
@ -29,6 +28,45 @@
@import 'tailwindcss'; @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 /* Beide Schreibweisen: Der Umschalter setzt `data-theme`, `.dark` ist die
* shadcn-übliche Form und kostet nichts. */ * shadcn-übliche Form und kostet nichts. */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *)); @custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *));

View File

@ -89,6 +89,46 @@ 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. 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 ## Grenzen des aktuellen Stands
**Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte: **Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte:

View File

@ -187,12 +187,17 @@ ExecStart=/opt/syncova/bin/syncova-api
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# Härtung: Der Dienst braucht Netz und sein Repository, sonst nichts. # 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.
NoNewPrivileges=yes NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
ReadWritePaths=/srv/syncova-repository ReadWritePaths=/srv/syncova-repository /srv/syncova-restore
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes
@ -285,6 +290,25 @@ sudo firewall-cmd --permanent --add-service=https # firewalld
sudo firewall-cmd --reload 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 ## 9. Erstes Repository
Ein Repository entsteht **auf einem Datenträger**, nicht in einer Ein Repository entsteht **auf einem Datenträger**, nicht in einer

View File

@ -51,9 +51,44 @@ Original nicht mehr.
## 1. Dateien und Ordner ## 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 ### Über die Oberfläche
Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen* → Zielpfad angeben. 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.**
### Über die API ### Über die API
@ -68,7 +103,16 @@ curl -X POST https://<server>/api/v1/restores \
}' }'
``` ```
`path_prefix` beschränkt auf einen Teilbaum. Ohne ihn kommt alles zurück. `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'
```
Fortschritt: Fortschritt:

View File

@ -0,0 +1,8 @@
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;

View File

@ -0,0 +1,48 @@
-- 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';

View File

@ -37,3 +37,5 @@ e559f3d4443b9057a3f40a685141d92f3e05817da725857d2f0cba231aef3035 000005_restore
e6568d4614f3ebf5a66b75f2b8b3b0ee80786c04c6165216964395c7c1834db8 000009_alerts.up.sql e6568d4614f3ebf5a66b75f2b8b3b0ee80786c04c6165216964395c7c1834db8 000009_alerts.up.sql
f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql
ffbd9245f8031c902f30a81cbaffcf9e61e515cc77ccaf9e885186e737cca3d0 000003_agents.up.sql ffbd9245f8031c902f30a81cbaffcf9e61e515cc77ccaf9e885186e737cca3d0 000003_agents.up.sql
f25274d148996038f5716f315470c4f9371629ad734a03f0ee49ee760ea4c2d5 000014_backup_mode.up.sql
9a48a9de411d6af07461c236967525469c73d820a4d282fab8a61ed6eb56f9f3 000014_backup_mode.down.sql

View File

@ -0,0 +1,91 @@
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")
}
}

View File

@ -423,6 +423,13 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
return jobs.ExecutionResult{}, parentError 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) backupIdentifier := buildBackupIdentifier(backupRequest.RunID, backupRequest.Source)
startTime := time.Now().UTC() startTime := time.Now().UTC()
@ -439,7 +446,7 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
EncryptionEnabled: executor.options.SecretStore != nil, EncryptionEnabled: executor.options.SecretStore != nil,
ChainID: chainIdentifier.String(), ChainID: chainIdentifier.String(),
CreatedByVersion: executor.options.CreatedByVersion, CreatedByVersion: executor.options.CreatedByVersion,
Incremental: parentBackupInRepository != "", Incremental: parentBackupInRepository != "" && !forceFullBackup,
ParentBackupID: parentBackupInRepository, ParentBackupID: parentBackupInRepository,
BandwidthLimiter: backupRequest.Limiter, BandwidthLimiter: backupRequest.Limiter,
} }
@ -691,3 +698,41 @@ func hasServerSideSource(executionJob *jobs.Job) bool {
return false 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
}

View File

@ -82,6 +82,22 @@ type JobSource struct {
ExcludePatterns []string `json:"exclude_patterns,omitempty"` 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. // Job ist ein Sicherungsauftrag.
type Job struct { type Job struct {
// ID ist der öffentliche Bezeichner. // ID ist der öffentliche Bezeichner.
@ -110,6 +126,18 @@ type Job struct {
RecoveryTimeObjective time.Duration `json:"rto,omitempty"` RecoveryTimeObjective time.Duration `json:"rto,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt. // BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` 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 begrenzt gleichzeitige Läufe dieses Auftrags.
MaximumConcurrency int `json:"max_concurrency"` MaximumConcurrency int `json:"max_concurrency"`
// RetryPolicy beschreibt das Wiederholungsverhalten. // RetryPolicy beschreibt das Wiederholungsverhalten.

View File

@ -72,8 +72,9 @@ func (store *PostgresStore) CreateJob(createContext context.Context, newJob *Job
INSERT INTO backup_jobs ( INSERT INTO backup_jobs (
name, description, status, priority, schedule_type, schedule_config, name, description, status, priority, schedule_type, schedule_config,
repository_id, retention_policy_id, rpo_seconds, rto_seconds, repository_id, retention_policy_id, rpo_seconds, rto_seconds,
bandwidth_limit_bps, max_concurrency, retry_policy, next_run_at, created_by 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) backup_mode, full_backup_weekday
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING id` RETURNING id`
var createdJobID uuid.UUID var createdJobID uuid.UUID
@ -94,6 +95,8 @@ func (store *PostgresStore) CreateJob(createContext context.Context, newJob *Job
retryJSON, retryJSON,
newJob.NextRunAt, newJob.NextRunAt,
newJob.CreatedBy, newJob.CreatedBy,
string(normalizeBackupMode(newJob.BackupMode)),
nullableWeekday(newJob.FullBackupWeekday),
).Scan(&createdJobID) ).Scan(&createdJobID)
if scanError != nil { if scanError != nil {
@ -175,7 +178,7 @@ func (store *PostgresStore) GetJob(readContext context.Context, jobIdentifier uu
const selectJobStatement = ` const selectJobStatement = `
SELECT id, name, COALESCE(description,''), status, priority, schedule_config, SELECT id, name, COALESCE(description,''), status, priority, schedule_config,
repository_id, retention_policy_id, rpo_seconds, rto_seconds, repository_id, retention_policy_id, rpo_seconds, rto_seconds,
bandwidth_limit_bps, max_concurrency, retry_policy, bandwidth_limit_bps, max_concurrency, retry_policy, backup_mode, full_backup_weekday,
next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at
FROM backup_jobs FROM backup_jobs
WHERE id = $1 AND deleted_at IS NULL` WHERE id = $1 AND deleted_at IS NULL`
@ -219,6 +222,10 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
bandwidthLimit *int64 bandwidthLimit *int64
lastOutcomeText *string lastOutcomeText *string
descriptionValue 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( scanError := scanner.Scan(
@ -235,6 +242,8 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
&bandwidthLimit, &bandwidthLimit,
&loadedJob.MaximumConcurrency, &loadedJob.MaximumConcurrency,
&retryJSON, &retryJSON,
&backupModeText,
&fullBackupWeekday,
&loadedJob.NextRunAt, &loadedJob.NextRunAt,
&loadedJob.LastRunAt, &loadedJob.LastRunAt,
&lastOutcomeText, &lastOutcomeText,
@ -250,6 +259,8 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
loadedJob.Description = descriptionValue loadedJob.Description = descriptionValue
loadedJob.Status = JobStatus(statusText) loadedJob.Status = JobStatus(statusText)
loadedJob.Priority = scheduler.Priority(priorityText) loadedJob.Priority = scheduler.Priority(priorityText)
loadedJob.BackupMode = normalizeBackupMode(BackupMode(backupModeText))
loadedJob.FullBackupWeekday = weekdayFromDatabase(fullBackupWeekday)
if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil { if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil {
return nil, fmt.Errorf("der zeitplan des auftrags %s ist unlesbar: %w", loadedJob.ID, unmarshalError) return nil, fmt.Errorf("der zeitplan des auftrags %s ist unlesbar: %w", loadedJob.ID, unmarshalError)
@ -406,7 +417,7 @@ func (store *PostgresStore) ListJobs(listContext context.Context, listFilter Lis
const selectStatement = ` const selectStatement = `
SELECT id, name, COALESCE(description,''), status, priority, schedule_config, SELECT id, name, COALESCE(description,''), status, priority, schedule_config,
repository_id, retention_policy_id, rpo_seconds, rto_seconds, repository_id, retention_policy_id, rpo_seconds, rto_seconds,
bandwidth_limit_bps, max_concurrency, retry_policy, bandwidth_limit_bps, max_concurrency, retry_policy, backup_mode, full_backup_weekday,
next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at, next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at,
COUNT(*) OVER () AS total_count COUNT(*) OVER () AS total_count
FROM backup_jobs FROM backup_jobs
@ -473,12 +484,17 @@ func scanJobRowWithTotal(jobRows pgx.Rows, totalCount *int) (*Job, error) {
bandwidthLimit *int64 bandwidthLimit *int64
lastOutcomeText *string lastOutcomeText *string
descriptionValue 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( scanError := jobRows.Scan(
&loadedJob.ID, &loadedJob.Name, &descriptionValue, &statusText, &priorityText, &scheduleJSON, &loadedJob.ID, &loadedJob.Name, &descriptionValue, &statusText, &priorityText, &scheduleJSON,
&loadedJob.RepositoryID, &loadedJob.RetentionPolicyID, &rpoSeconds, &rtoSeconds, &loadedJob.RepositoryID, &loadedJob.RetentionPolicyID, &rpoSeconds, &rtoSeconds,
&bandwidthLimit, &loadedJob.MaximumConcurrency, &retryJSON, &bandwidthLimit, &loadedJob.MaximumConcurrency, &retryJSON,
&backupModeText, &fullBackupWeekday,
&loadedJob.NextRunAt, &loadedJob.LastRunAt, &lastOutcomeText, &loadedJob.PausedAt, &loadedJob.NextRunAt, &loadedJob.LastRunAt, &lastOutcomeText, &loadedJob.PausedAt,
&loadedJob.CreatedBy, &loadedJob.CreatedAt, &loadedJob.UpdatedAt, &loadedJob.CreatedBy, &loadedJob.CreatedAt, &loadedJob.UpdatedAt,
totalCount, totalCount,
@ -490,6 +506,8 @@ func scanJobRowWithTotal(jobRows pgx.Rows, totalCount *int) (*Job, error) {
loadedJob.Description = descriptionValue loadedJob.Description = descriptionValue
loadedJob.Status = JobStatus(statusText) loadedJob.Status = JobStatus(statusText)
loadedJob.Priority = scheduler.Priority(priorityText) loadedJob.Priority = scheduler.Priority(priorityText)
loadedJob.BackupMode = normalizeBackupMode(BackupMode(backupModeText))
loadedJob.FullBackupWeekday = weekdayFromDatabase(fullBackupWeekday)
if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil { if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil {
return nil, unmarshalError return nil, unmarshalError
@ -714,3 +732,51 @@ func defaultToEmptySlice(patternList []string) []string {
return patternList 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
}

View File

@ -40,6 +40,28 @@ readonly defaultRepositoryPath="/srv/syncova-repository"
# backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf. # backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf.
readonly backupDirectory="/var/backups/syncova" 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 # Ausgabe
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -212,6 +234,10 @@ while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--paket) packageDirectory="${2:-}"; shift 2 ;; --paket) packageDirectory="${2:-}"; shift 2 ;;
--repository) repositoryPath="${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 ;; --adresse) listenAddress="${2:-}"; shift 2 ;;
--admin) administratorName="${2:-}"; shift 2 ;; --admin) administratorName="${2:-}"; shift 2 ;;
--ohne-haertung) createHardenedRepository="nein"; shift ;; --ohne-haertung) createHardenedRepository="nein"; shift ;;
@ -1047,6 +1073,11 @@ noteCreated "directory:${configurationDirectory}"
install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${backupDirectory}" 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}/" cp -R "${packageDirectory}/bin" "${installationRoot}/"
[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/" [[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/"
[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/" [[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/"
@ -1296,7 +1327,7 @@ NoNewPrivileges=yes
PrivateTmp=${privateTmpSetting} PrivateTmp=${privateTmpSetting}
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
ReadWritePaths=${repositoryPath} ${backupDirectory} ReadWritePaths=${repositoryPath} ${backupDirectory} ${restoreDirectory}${additionalRestoreRoots}
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes

View File

@ -30,8 +30,16 @@ readonly installationRoot="/opt/syncova"
readonly configurationDirectory="/etc/syncova" readonly configurationDirectory="/etc/syncova"
readonly configurationFile="${configurationDirectory}/syncova.env" readonly configurationFile="${configurationDirectory}/syncova.env"
readonly serviceName="syncova-api" readonly serviceName="syncova-api"
readonly serviceAccount="syncova"
readonly backupDirectory="/var/backups/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. # previousReleaseRoot nimmt die abgeloeste Fassung auf.
# #
# Sie bleibt liegen, bis die neue nachweislich laeuft. Ein Update, das die alte # Sie bleibt liegen, bis die neue nachweislich laeuft. Ein Update, das die alte
@ -419,6 +427,41 @@ fi
writeSuccess "Schema aktuell" 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 # 7. Starten und pruefen
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

0
untitled.md Normal file
View File