feat(audit): JSONL-Logger mit Größenrotation, gzip und SIGHUP-Reopen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
b47ee11ba4
commit
2eb69bdcfe
5 changed files with 526 additions and 0 deletions
159
internal/audit/rotate.go
Normal file
159
internal/audit/rotate.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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:]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue