syncova-backup/apps/api/internal/httpapi/browse_handler.go
Jerrit Fritzsche 20b0919676
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
Datei- und Ordnerwiederherstellung, Ordnerbaum, Geist Mono im Paket
**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

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
}