opnsense-portal/internal/audit/rotate.go
Carsten Abele 2eb69bdcfe 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
2026-08-14 09:08:19 +02:00

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:]
}
}