**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>
423 lines
14 KiB
Go
423 lines
14 KiB
Go
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
|
|
}
|