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>
460 lines
16 KiB
Go
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
|
|
}
|