Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
159 lines
3.4 KiB
Go
159 lines
3.4 KiB
Go
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:]
|
|
}
|
|
}
|