200 lines
4.8 KiB
Go
200 lines
4.8 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"
|
|
"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
|
|
}
|
|
|
|
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.
|
|
_ = openLogFileLocked()
|
|
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)
|
|
}
|
|
}
|
|
}
|