// 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 }