opnsense-portal/internal/audit/logger.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

77 lines
1.7 KiB
Go

package audit
import (
"encoding/json"
"fmt"
"io"
"sync"
"time"
)
// Options steuert Rotation und Nebenausgabe.
type Options struct {
MaxSizeMB int
MaxBackups int
Compress bool
// Stdout erhält eine Kopie jeder Zeile (systemd-Journal). Nil = keine Kopie.
Stdout io.Writer
// Clock ist injizierbar für Tests. Nil = time.Now.
Clock func() time.Time
}
// Logger schreibt Events zeilenweise als JSON.
type Logger struct {
mu sync.Mutex
file *rotatingFile
stdout io.Writer
clock func() time.Time
}
// New öffnet das Audit-Log und liefert einen Logger.
// Ein fehlendes Verzeichnis wird angelegt.
func New(path string, opts Options) (*Logger, error) {
if opts.Clock == nil {
opts.Clock = time.Now
}
rf, err := openRotating(path, opts)
if err != nil {
return nil, err
}
return &Logger{file: rf, stdout: opts.Stdout, clock: opts.Clock}, nil
}
// Log schreibt ein Ereignis. Fehler beim Schreiben werden nach stdout gemeldet,
// dürfen den laufenden Request aber nie scheitern lassen.
func (l *Logger) Log(e Event) {
if e.TS == "" {
e.TS = l.clock().Format(time.RFC3339)
}
line, err := json.Marshal(e)
if err != nil {
return // Event enthält nur Strings; kann praktisch nicht passieren
}
line = append(line, '\n')
l.mu.Lock()
defer l.mu.Unlock()
if _, err := l.file.Write(line); err != nil && l.stdout != nil {
fmt.Fprintf(l.stdout, "{\"ts\":%q,\"event\":\"audit_write_failed\",\"detail\":%q}\n",
e.TS, err.Error())
}
if l.stdout != nil {
l.stdout.Write(line)
}
}
// Reopen schließt die aktuelle Datei und öffnet sie neu (SIGHUP/logrotate).
func (l *Logger) Reopen() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.file.reopen()
}
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.file.Close()
}