mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-16 06:42:23 -05:00
If both the rename and the reopen fail, logFile stayed nil while initialized was still true, panicking on the next write. Propagate the reopen error and retry the open on the next write so it self-heals.
212 lines
5.2 KiB
Go
212 lines
5.2 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package audit
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"code.vikunja.io/api/pkg/config"
|
|
"code.vikunja.io/api/pkg/log"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
initialized bool
|
|
logFile *os.File
|
|
logfilePath string
|
|
currentSize int64
|
|
maxSizeBytes int64
|
|
maxAge time.Duration
|
|
lastSync time.Time
|
|
)
|
|
|
|
// Init opens the audit log file.
|
|
// Safe to call again to re-read the config (used by tests).
|
|
func Init() error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
closeLocked()
|
|
|
|
logfilePath = config.AuditLogfile.GetString()
|
|
if logfilePath == "" {
|
|
logfilePath = filepath.Join(config.LogPath.GetString(), "audit.log")
|
|
}
|
|
maxSizeBytes = config.AuditRotationMaxSizeMB.GetInt64() * 1024 * 1024
|
|
maxAge = time.Duration(config.AuditRotationMaxAge.GetInt64()) * 24 * time.Hour
|
|
|
|
if err := os.MkdirAll(filepath.Dir(logfilePath), 0750); err != nil {
|
|
return fmt.Errorf("could not create audit log directory: %w", err)
|
|
}
|
|
if err := openLogFileLocked(); err != nil {
|
|
return err
|
|
}
|
|
|
|
initialized = true
|
|
return nil
|
|
}
|
|
|
|
// Close closes the audit log file. Used by tests.
|
|
func Close() {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
closeLocked()
|
|
}
|
|
|
|
func closeLocked() {
|
|
if logFile != nil {
|
|
_ = logFile.Sync()
|
|
_ = logFile.Close()
|
|
logFile = nil
|
|
}
|
|
initialized = false
|
|
}
|
|
|
|
func openLogFileLocked() error {
|
|
f, err := os.OpenFile(logfilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("could not open audit log file %s: %w", logfilePath, err)
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return fmt.Errorf("could not stat audit log file %s: %w", logfilePath, err)
|
|
}
|
|
logFile = f
|
|
currentSize = info.Size()
|
|
return nil
|
|
}
|
|
|
|
// WriteAuditEvent writes one entry to the local audit log. A failed write is
|
|
// returned so the event router retries it.
|
|
func WriteAuditEvent(entry *Entry) error {
|
|
if entry.EventID == "" {
|
|
id, err := uuid.NewV7()
|
|
if err != nil {
|
|
return fmt.Errorf("could not generate audit event id: %w", err)
|
|
}
|
|
entry.EventID = id.String()
|
|
}
|
|
if entry.Timestamp.IsZero() {
|
|
entry.Timestamp = time.Now().UTC()
|
|
}
|
|
if entry.Outcome == "" {
|
|
entry.Outcome = OutcomeSuccess
|
|
}
|
|
|
|
line, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return fmt.Errorf("could not marshal audit entry: %w", err)
|
|
}
|
|
|
|
mu.Lock()
|
|
if !initialized {
|
|
mu.Unlock()
|
|
return fmt.Errorf("audit log not initialized")
|
|
}
|
|
|
|
if err := rotateIfNeededLocked(int64(len(line)) + 1); err != nil {
|
|
mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
// A failed rotation can leave us without an open file — retry the open
|
|
// here so writes self-heal via the router's retries instead of panicking.
|
|
if logFile == nil {
|
|
if err := openLogFileLocked(); err != nil {
|
|
mu.Unlock()
|
|
return err
|
|
}
|
|
}
|
|
|
|
written, err := logFile.Write(append(line, '\n'))
|
|
currentSize += int64(written)
|
|
if err == nil && time.Since(lastSync) > time.Second {
|
|
err = logFile.Sync()
|
|
lastSync = time.Now()
|
|
}
|
|
mu.Unlock()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("could not write audit entry: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func rotateIfNeededLocked(addition int64) error {
|
|
if maxSizeBytes <= 0 || currentSize+addition <= maxSizeBytes {
|
|
return nil
|
|
}
|
|
|
|
_ = logFile.Sync()
|
|
_ = logFile.Close()
|
|
logFile = nil
|
|
|
|
rotatedPath := rotatedFileName(logfilePath, time.Now().UTC())
|
|
if err := os.Rename(logfilePath, rotatedPath); err != nil {
|
|
// Reopen the original so logging continues even if rotation failed.
|
|
if openErr := openLogFileLocked(); openErr != nil {
|
|
return errors.Join(fmt.Errorf("could not rotate audit log: %w", err), openErr)
|
|
}
|
|
return fmt.Errorf("could not rotate audit log: %w", err)
|
|
}
|
|
|
|
cleanupRotatedFiles()
|
|
|
|
return openLogFileLocked()
|
|
}
|
|
|
|
func rotatedFileName(path string, now time.Time) string {
|
|
ext := filepath.Ext(path)
|
|
return strings.TrimSuffix(path, ext) + "-" + now.Format("20060102T150405.000") + ext
|
|
}
|
|
|
|
func cleanupRotatedFiles() {
|
|
if maxAge <= 0 {
|
|
return
|
|
}
|
|
|
|
ext := filepath.Ext(logfilePath)
|
|
pattern := strings.TrimSuffix(logfilePath, ext) + "-*" + ext
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
log.Errorf("Could not list rotated audit log files: %s", err)
|
|
return
|
|
}
|
|
|
|
cutoff := time.Now().Add(-maxAge)
|
|
for _, match := range matches {
|
|
info, err := os.Stat(match)
|
|
if err != nil || info.ModTime().After(cutoff) {
|
|
continue
|
|
}
|
|
if err := os.Remove(match); err != nil {
|
|
log.Errorf("Could not remove old audit log file %s: %s", match, err)
|
|
}
|
|
}
|
|
}
|