syncova-backup/packages/agent/discovery.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

460 lines
16 KiB
Go

// Package agent enthält den plattformunabhängigen Kern des Syncova-Agents:
// Erfassung der zu sichernden Dateien, Anmeldung am Control Server und
// Lebendmeldung.
//
// Die plattformspezifischen Teile — Windows-Dienst und systemd — liegen
// bewusst außerhalb dieses Pakets in apps/agent. Der hier enthaltene Kern
// verhält sich auf allen Plattformen gleich und ist damit vollständig prüfbar.
package agent
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// EntryType benennt die Art eines erfassten Objekts.
type EntryType string
const (
// EntryTypeFile ist eine gewöhnliche Datei.
EntryTypeFile EntryType = "file"
// EntryTypeDirectory ist ein Verzeichnis.
EntryTypeDirectory EntryType = "directory"
// EntryTypeSymlink ist ein symbolischer Verweis.
EntryTypeSymlink EntryType = "symlink"
)
// DiscoveredEntry beschreibt ein erfasstes Objekt.
type DiscoveredEntry struct {
// AbsolutePath ist der vollständige Pfad im Dateisystem.
AbsolutePath string `json:"absolute_path"`
// RelativePath ist der Pfad unterhalb des erfassten Wurzelverzeichnisses.
//
// Er wird im Backup abgelegt, damit eine Wiederherstellung auch an einem
// anderen Ort möglich ist.
RelativePath string `json:"relative_path"`
// EntryType ist die Art des Objekts.
EntryType EntryType `json:"type"`
// SizeBytes ist die Größe in Byte; bei Verzeichnissen 0.
SizeBytes int64 `json:"size_bytes"`
// ModifiedAt ist der Änderungszeitpunkt in UTC.
ModifiedAt time.Time `json:"modified_at"`
// Mode sind die Dateirechte in oktaler Schreibweise.
Mode string `json:"mode"`
// LinkTarget ist das Ziel eines symbolischen Verweises.
LinkTarget string `json:"link_target,omitempty"`
}
// DiscoveryProblem beschreibt ein beim Erfassen aufgetretenes Problem.
//
// Probleme werden gesammelt statt den Lauf abzubrechen: eine einzelne
// unlesbare Datei darf nicht verhindern, dass die übrigen gesichert werden.
// Verschwiegen werden dürfen sie aber niemals — ein Backup mit übergangenen
// Dateien ist ein Teilfehler, kein Erfolg (PROMPT.md §140).
type DiscoveryProblem struct {
// Path ist das betroffene Objekt.
Path string `json:"path"`
// Reason erklärt das Problem verständlich.
Reason string `json:"reason"`
// IsPermissionDenied meldet einen Rechtefehler.
//
// Er wird gesondert ausgewiesen, weil er fast immer eine Fehlkonfiguration
// des Agent-Kontos bedeutet und nicht einen defekten Datenträger.
IsPermissionDenied bool `json:"is_permission_denied"`
// IsUnsupportedType meldet ein Objekt ohne sicherbaren Inhalt.
//
// **Der Unterschied zu einem Fehler ist der wichtigste dieser Struktur.**
//
// Ein Socket, eine benannte Pipe oder eine Gerätedatei *fehlt* nicht im
// Backup — sie *gehört* nicht hinein. Sie hat keinen Inhalt, den man
// sichern und zurückschreiben könnte; ein Socket ist ein Endpunkt eines
// laufenden Prozesses, keine Datei.
//
// Zählte man sie als übergangenes Objekt, wäre jede Sicherung eines
// Linux-Systems ein Teilfehler: In /var/run und /tmp liegen ständig
// Sockets. Nach einer Woche klickt niemand mehr einen Teilfehler an — und
// dann fällt auch der echte nicht mehr auf (dieselbe Überlegung wie beim
// Meldungswesen, Phase 14).
IsUnsupportedType bool `json:"is_unsupported_type"`
}
// IsDataLoss meldet ein Problem, das Daten aus dem Backup fernhält.
//
// Nur solche Probleme machen einen Lauf zum Teilfehler. Ein Objekt ohne
// sicherbaren Inhalt gehört nicht dazu.
func (problem DiscoveryProblem) IsDataLoss() bool {
return !problem.IsUnsupportedType
}
// DiscoveryResult ist das Ergebnis einer Erfassung.
type DiscoveryResult struct {
// Entries sind die erfassten Objekte, nach Pfad sortiert.
//
// Die feste Reihenfolge macht zwei Läufe über denselben Bestand
// vergleichbar und die entstehenden Backups reproduzierbar.
Entries []DiscoveredEntry `json:"entries"`
// Problems sind die aufgetretenen Probleme.
Problems []DiscoveryProblem `json:"problems"`
// TotalBytes ist die Gesamtgröße aller erfassten Dateien.
TotalBytes int64 `json:"total_bytes"`
// SkippedByPattern ist die Zahl durch Ausschlussregeln übergangener Objekte.
SkippedByPattern int `json:"skipped_by_pattern"`
}
// HasProblems meldet, ob beim Erfassen Probleme auftraten.
//
// Einschliesslich der blossen Vermerke. Wer wissen will, ob **Daten fehlen**,
// fragt DataLossProblemCount.
func (discoveryResult *DiscoveryResult) HasProblems() bool {
return len(discoveryResult.Problems) > 0
}
// PermissionProblemCount zählt die Rechtefehler.
func (discoveryResult *DiscoveryResult) PermissionProblemCount() int {
var problemCount int
for _, discoveryProblem := range discoveryResult.Problems {
if discoveryProblem.IsPermissionDenied {
problemCount++
}
}
return problemCount
}
// FileCount zählt die erfassten Dateien ohne Verzeichnisse.
func (discoveryResult *DiscoveryResult) FileCount() int {
var fileCount int
for _, discoveredEntry := range discoveryResult.Entries {
if discoveredEntry.EntryType == EntryTypeFile {
fileCount++
}
}
return fileCount
}
// DiscoveryOptions steuern die Erfassung (PROMPT.md §6).
type DiscoveryOptions struct {
// IncludePatterns beschränken die Erfassung auf passende Pfade.
//
// Eine leere Liste bedeutet: alles einschließen.
IncludePatterns []string
// ExcludePatterns schließen passende Pfade aus.
//
// Ausschluss geht vor Einschluss: wer etwas ausdrücklich ausnimmt, meint es.
ExcludePatterns []string
// FollowSymlinks legt fest, ob symbolischen Verweisen gefolgt wird.
//
// Standardmäßig wird ihnen nicht gefolgt: ein Verweis auf ein
// übergeordnetes Verzeichnis erzeugte sonst eine endlose Schleife, und ein
// Verweis nach außen zöge unbeabsichtigt fremde Daten ins Backup.
FollowSymlinks bool
// IncludeDirectories nimmt Verzeichnisse als eigene Einträge auf.
//
// Sie tragen keine Daten, aber ihre Rechte — nötig für eine vollständige
// Wiederherstellung.
IncludeDirectories bool
// MaximumFileSize begrenzt die Größe einzelner Dateien; 0 bedeutet unbegrenzt.
MaximumFileSize int64
}
// ErrRootNotFound meldet ein nicht vorhandenes Wurzelverzeichnis.
var ErrRootNotFound = errors.New("das zu sichernde verzeichnis existiert nicht")
// Discover erfasst die zu sichernden Objekte unterhalb eines Wurzelverzeichnisses.
//
// Der Lauf bricht nur ab, wenn das Wurzelverzeichnis selbst unzugänglich ist.
// Alles Weitere wird als Problem vermerkt und der Lauf fortgesetzt: eine
// einzelne gesperrte Datei darf nicht das ganze Backup verhindern.
func Discover(rootPath string, discoveryOptions DiscoveryOptions) (*DiscoveryResult, error) {
absoluteRoot, pathError := filepath.Abs(rootPath)
if pathError != nil {
return nil, fmt.Errorf("der pfad %q konnte nicht aufgelöst werden: %w", rootPath, pathError)
}
rootInformation, statError := os.Stat(absoluteRoot)
if statError != nil {
if errors.Is(statError, os.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", ErrRootNotFound, absoluteRoot)
}
return nil, fmt.Errorf("auf %q kann nicht zugegriffen werden: %w", absoluteRoot, statError)
}
discoveryResult := &DiscoveryResult{
Entries: make([]DiscoveredEntry, 0, 64),
Problems: make([]DiscoveryProblem, 0),
}
// Eine einzelne Datei als Wurzel ist ein zulässiger Fall.
if !rootInformation.IsDir() {
parentDirectory := filepath.Dir(absoluteRoot)
appendEntry(discoveryResult, absoluteRoot, parentDirectory, rootInformation, discoveryOptions)
return discoveryResult, nil
}
walkError := filepath.WalkDir(absoluteRoot, func(currentPath string, directoryEntry fs.DirEntry, walkError error) error {
if walkError != nil {
// Ein Fehler beim Betreten wird vermerkt; der Lauf geht weiter.
recordProblem(discoveryResult, currentPath, walkError)
// Ein unlesbares Verzeichnis wird übersprungen, nicht der ganze Lauf.
if directoryEntry != nil && directoryEntry.IsDir() {
return filepath.SkipDir
}
return nil
}
// Das Wurzelverzeichnis selbst wird nicht als Eintrag aufgenommen.
if currentPath == absoluteRoot {
return nil
}
relativePath, relativeError := filepath.Rel(absoluteRoot, currentPath)
if relativeError != nil {
recordProblem(discoveryResult, currentPath, relativeError)
return nil
}
if isExcluded(relativePath, directoryEntry.IsDir(), discoveryOptions) {
discoveryResult.SkippedByPattern++
// Ein ausgeschlossenes Verzeichnis wird gar nicht erst betreten.
if directoryEntry.IsDir() {
return filepath.SkipDir
}
return nil
}
entryInformation, infoError := directoryEntry.Info()
if infoError != nil {
recordProblem(discoveryResult, currentPath, infoError)
return nil
}
appendEntry(discoveryResult, currentPath, absoluteRoot, entryInformation, discoveryOptions)
return nil
})
if walkError != nil {
return nil, fmt.Errorf("die erfassung von %q brach ab: %w", absoluteRoot, walkError)
}
// Die feste Reihenfolge macht zwei Läufe vergleichbar.
sort.Slice(discoveryResult.Entries, func(firstIndex int, secondIndex int) bool {
return discoveryResult.Entries[firstIndex].RelativePath < discoveryResult.Entries[secondIndex].RelativePath
})
return discoveryResult, nil
}
// appendEntry nimmt ein Objekt in das Ergebnis auf.
func appendEntry(discoveryResult *DiscoveryResult, absolutePath string, rootPath string, entryInformation os.FileInfo, discoveryOptions DiscoveryOptions) {
relativePath, relativeError := filepath.Rel(rootPath, absolutePath)
if relativeError != nil {
relativePath = filepath.Base(absolutePath)
}
// Pfade werden mit Schrägstrich abgelegt, unabhängig von der Plattform.
// Andernfalls liesse sich ein auf Windows erstelltes Backup unter Linux
// nicht sauber wiederherstellen.
relativePath = filepath.ToSlash(relativePath)
switch {
case entryInformation.IsDir():
if !discoveryOptions.IncludeDirectories {
return
}
discoveryResult.Entries = append(discoveryResult.Entries, DiscoveredEntry{
AbsolutePath: absolutePath,
RelativePath: relativePath,
EntryType: EntryTypeDirectory,
ModifiedAt: entryInformation.ModTime().UTC(),
Mode: fmt.Sprintf("%04o", entryInformation.Mode().Perm()),
})
case entryInformation.Mode()&os.ModeSymlink != 0:
// Das Ziel wird mitgesichert, der Verweis selbst aber nicht verfolgt.
linkTarget, linkError := os.Readlink(absolutePath)
if linkError != nil {
recordProblem(discoveryResult, absolutePath, linkError)
return
}
discoveryResult.Entries = append(discoveryResult.Entries, DiscoveredEntry{
AbsolutePath: absolutePath,
RelativePath: relativePath,
EntryType: EntryTypeSymlink,
ModifiedAt: entryInformation.ModTime().UTC(),
Mode: fmt.Sprintf("%04o", entryInformation.Mode().Perm()),
LinkTarget: linkTarget,
})
case entryInformation.Mode().IsRegular():
// Eine zu große Datei wird ausdrücklich vermerkt, nicht stillschweigend
// übergangen.
if discoveryOptions.MaximumFileSize > 0 && entryInformation.Size() > discoveryOptions.MaximumFileSize {
discoveryResult.Problems = append(discoveryResult.Problems, DiscoveryProblem{
Path: absolutePath,
Reason: fmt.Sprintf("Die Datei ist mit %d Byte größer als die zulässige Höchstgröße von %d Byte.",
entryInformation.Size(), discoveryOptions.MaximumFileSize),
})
return
}
discoveryResult.Entries = append(discoveryResult.Entries, DiscoveredEntry{
AbsolutePath: absolutePath,
RelativePath: relativePath,
EntryType: EntryTypeFile,
SizeBytes: entryInformation.Size(),
ModifiedAt: entryInformation.ModTime().UTC(),
Mode: fmt.Sprintf("%04o", entryInformation.Mode().Perm()),
})
discoveryResult.TotalBytes += entryInformation.Size()
default:
// Gerätedateien, Sockets und benannte Pipes tragen keinen sicherbaren
// Inhalt. Sie werden vermerkt, damit niemand annimmt, sie seien im
// Backup enthalten — aber sie zählen **nicht** als übergangenes Objekt:
// Sie fehlen nicht, sie gehören nicht hinein.
discoveryResult.Problems = append(discoveryResult.Problems, DiscoveryProblem{
Path: absolutePath,
Reason: fmt.Sprintf("Der Objekttyp %s hat keinen sicherbaren Inhalt und wird "+
"übergangen. Das ist kein Fehler.", describeObjectType(entryInformation.Mode())),
IsUnsupportedType: true,
})
}
}
// recordProblem vermerkt ein Problem im Ergebnis.
func recordProblem(discoveryResult *DiscoveryResult, objectPath string, occurredError error) {
isPermissionDenied := errors.Is(occurredError, os.ErrPermission)
problemReason := occurredError.Error()
if isPermissionDenied {
// Eine verständliche Formulierung statt der Systemmeldung (PROMPT.md §124).
problemReason = "Der Zugriff wurde verweigert. Das Konto des Agents besitzt keine Leseberechtigung."
}
discoveryResult.Problems = append(discoveryResult.Problems, DiscoveryProblem{
Path: objectPath,
Reason: problemReason,
IsPermissionDenied: isPermissionDenied,
})
}
// isExcluded prüft, ob ein Pfad durch die Regeln ausgeschlossen ist.
func isExcluded(relativePath string, isDirectory bool, discoveryOptions DiscoveryOptions) bool {
normalizedPath := filepath.ToSlash(relativePath)
// Ausschluss geht vor Einschluss: wer etwas ausdrücklich ausnimmt, meint es.
for _, excludePattern := range discoveryOptions.ExcludePatterns {
if matchesPattern(normalizedPath, excludePattern) {
return true
}
}
if len(discoveryOptions.IncludePatterns) == 0 {
return false
}
// Ein Verzeichnis wird nie allein wegen der Einschlussregeln übergangen:
// darunter könnten passende Dateien liegen.
if isDirectory {
return false
}
for _, includePattern := range discoveryOptions.IncludePatterns {
if matchesPattern(normalizedPath, includePattern) {
return false
}
}
return true
}
// matchesPattern prüft ein Muster gegen einen Pfad.
//
// Neben dem Vergleich auf den Gesamtpfad wird auch der Dateiname geprüft. Damit
// wirkt ein Muster wie "*.tmp" wie erwartet, ohne dass der Anwender den
// vollständigen Pfad angeben muss.
func matchesPattern(normalizedPath string, matchPattern string) bool {
normalizedPattern := filepath.ToSlash(matchPattern)
if matched, matchError := filepath.Match(normalizedPattern, normalizedPath); matchError == nil && matched {
return true
}
if matched, matchError := filepath.Match(normalizedPattern, filepath.Base(normalizedPath)); matchError == nil && matched {
return true
}
// Ein Muster ohne Platzhalter wirkt auf den gesamten Teilbaum: "logs"
// schließt auch "logs/heute/app.log" aus.
if !strings.ContainsAny(normalizedPattern, "*?[") {
if normalizedPath == normalizedPattern || strings.HasPrefix(normalizedPath, normalizedPattern+"/") {
return true
}
}
return false
}
// timeoutAfterSeconds liefert einen Kanal, der nach der angegebenen Zeit schließt.
//
// Der Helfer wird von Tests gebraucht, die belegen müssen, dass ein Lauf
// überhaupt endet — etwa bei einer Verweisschleife.
func timeoutAfterSeconds(secondCount int) <-chan time.Time {
return time.After(time.Duration(secondCount) * time.Second)
}
// describeObjectType benennt einen Objekttyp in Worten.
//
// „prw-r--r--" sagt einem Betreiber nichts. „benannte Pipe (FIFO)" sagt ihm,
// dass er nichts zu tun hat.
func describeObjectType(fileMode os.FileMode) string {
switch {
case fileMode&os.ModeSocket != 0:
return "Socket"
case fileMode&os.ModeNamedPipe != 0:
return "benannte Pipe (FIFO)"
case fileMode&os.ModeDevice != 0 && fileMode&os.ModeCharDevice != 0:
return "Zeichengerät"
case fileMode&os.ModeDevice != 0:
return "Blockgerät"
case fileMode&os.ModeIrregular != 0:
return "unbekannter Objekttyp"
default:
return fileMode.Type().String()
}
}
// DataLossProblemCount zählt die Probleme, die Daten fernhalten.
//
// Sie ist die Zahl, die über Teilfehler oder Erfolg entscheidet — nicht die
// Gesamtzahl der Vermerke.
func (discoveryResult *DiscoveryResult) DataLossProblemCount() int {
dataLossCount := 0
for _, problem := range discoveryResult.Problems {
if problem.IsDataLoss() {
dataLossCount++
}
}
return dataLossCount
}