package audit import ( "compress/gzip" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" ) // logFileMode: nur der Eigentümer darf lesen und schreiben — das Log enthält // personenbezogene Daten. const logFileMode os.FileMode = 0o600 // rotatingFile ist ein größenrotierender Writer ohne externe Dependency. // Nicht selbst gesperrt — der Aufrufer (Logger) hält das Mutex. type rotatingFile struct { path string maxBytes int64 maxBackups int compress bool f *os.File size int64 } func openRotating(path string, opts Options) (*rotatingFile, error) { if opts.MaxSizeMB <= 0 { opts.MaxSizeMB = 50 } rf := &rotatingFile{ path: path, maxBytes: int64(opts.MaxSizeMB) * 1024 * 1024, maxBackups: opts.MaxBackups, compress: opts.Compress, } if err := rf.open(); err != nil { return nil, err } return rf, nil } func (r *rotatingFile) open() error { if err := os.MkdirAll(filepath.Dir(r.path), 0o750); err != nil { return fmt.Errorf("Log-Verzeichnis %s: %w", filepath.Dir(r.path), err) } f, err := os.OpenFile(r.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, logFileMode) if err != nil { return fmt.Errorf("Audit-Log %s: %w", r.path, err) } info, err := f.Stat() if err != nil { f.Close() return fmt.Errorf("Audit-Log %s: %w", r.path, err) } r.f, r.size = f, info.Size() return nil } func (r *rotatingFile) Write(p []byte) (int, error) { if r.size+int64(len(p)) > r.maxBytes && r.size > 0 { if err := r.rotate(); err != nil { return 0, err } } n, err := r.f.Write(p) r.size += int64(n) return n, err } // reopen schließt und öffnet die Datei neu (SIGHUP nach externem logrotate). func (r *rotatingFile) reopen() error { if r.f != nil { r.f.Close() } return r.open() } func (r *rotatingFile) Close() error { if r.f == nil { return nil } err := r.f.Close() r.f = nil return err } func (r *rotatingFile) rotate() error { if err := r.f.Close(); err != nil { return err } backup := fmt.Sprintf("%s.%s", r.path, time.Now().UTC().Format("20060102T150405.000")) if err := os.Rename(r.path, backup); err != nil { return fmt.Errorf("Rotation von %s: %w", r.path, err) } if r.compress { if gzPath, err := gzipFile(backup); err == nil { os.Remove(backup) backup = gzPath } } if err := r.open(); err != nil { return err } r.pruneBackups() return nil } func gzipFile(path string) (string, error) { in, err := os.Open(path) if err != nil { return "", err } defer in.Close() out, err := os.OpenFile(path+".gz", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, logFileMode) if err != nil { return "", err } defer out.Close() zw := gzip.NewWriter(out) if _, err := io.Copy(zw, in); err != nil { return "", err } if err := zw.Close(); err != nil { return "", err } return path + ".gz", nil } // pruneBackups löscht die ältesten rotierten Dateien über maxBackups hinaus. func (r *rotatingFile) pruneBackups() { if r.maxBackups <= 0 { return } dir, base := filepath.Split(r.path) if dir == "" { dir = "." } entries, err := os.ReadDir(dir) if err != nil { return } var backups []string for _, e := range entries { name := e.Name() if name != base && strings.HasPrefix(name, base+".") { backups = append(backups, name) } } // Die Zeitstempel im Namen sind lexikografisch sortierbar. sort.Strings(backups) for len(backups) > r.maxBackups { os.Remove(filepath.Join(dir, backups[0])) backups = backups[1:] } }