// Package oplog writes bounded, privacy-safe local operation logs. package oplog import ( "fmt" "os" "path/filepath" "strings" "time" "proxyctl/internal/app" "proxyctl/internal/securefs" "golang.org/x/sys/unix" ) type Logger struct { path string maxBytes int64 rotations int uid int gid int owned bool } func New(path string, maxBytes int64, rotations int) *Logger { return &Logger{path: path, maxBytes: maxBytes, rotations: rotations} } func NewOwned(path string, maxBytes int64, rotations, uid, gid int) *Logger { return &Logger{path: path, maxBytes: maxBytes, rotations: rotations, uid: uid, gid: gid, owned: true} } func (l *Logger) Write(entry app.LogEntry) error { if l.maxBytes <= 0 { return nil } directory := filepath.Dir(l.path) if err := os.MkdirAll(directory, 0o700); err != nil { return fmt.Errorf("create log directory: %w", err) } if err := os.Chmod(directory, 0o700); err != nil { return fmt.Errorf("secure log directory: %w", err) } if l.owned && (os.Geteuid() != l.uid || os.Getegid() != l.gid) { if err := os.Chown(directory, l.uid, l.gid); err != nil { return fmt.Errorf("set log directory owner: %w", err) } } detail := strings.NewReplacer("\n", " ", "\r", " ").Replace(app.RedactLogDetail(entry.Detail)) if entry.Time.IsZero() { entry.Time = time.Now() } line := fmt.Sprintf("time=%s operation=%s target=%s result=%s detail=%s\n", entry.Time.UTC().Format("2006-01-02T15:04:05Z"), field(entry.Operation), field(entry.Target), field(entry.Result), detail) if int64(len(line)) > l.maxBytes { truncated := []byte(line[:l.maxBytes]) if len(truncated) > 0 { truncated[len(truncated)-1] = '\n' } line = string(truncated) } if info, err := os.Stat(l.path); err == nil && info.Size()+int64(len(line)) > l.maxBytes { if err := l.rotate(); err != nil { return err } } else if err != nil && !os.IsNotExist(err) { return fmt.Errorf("inspect operation log: %w", err) } file, err := securefs.OpenNoFollow(l.path, unix.O_CREAT|unix.O_APPEND|unix.O_WRONLY|unix.O_NONBLOCK, 0o600) if err != nil { return fmt.Errorf("open operation log: %w", err) } defer file.Close() fileInfo, err := file.Stat() if err != nil { return fmt.Errorf("inspect operation log: %w", err) } if !fileInfo.Mode().IsRegular() { return fmt.Errorf("operation log is not a regular file") } if err := file.Chmod(0o600); err != nil { return fmt.Errorf("secure operation log: %w", err) } if l.owned && (os.Geteuid() != l.uid || os.Getegid() != l.gid) { if err := file.Chown(l.uid, l.gid); err != nil { return fmt.Errorf("set operation log owner: %w", err) } } if _, err := file.WriteString(line); err != nil { return fmt.Errorf("write operation log: %w", err) } return nil } func field(value string) string { return strings.NewReplacer(" ", "_", "\n", "_", "\r", "_").Replace(value) } func (l *Logger) rotate() error { if l.rotations <= 0 { return os.Remove(l.path) } _ = os.Remove(fmt.Sprintf("%s.%d", l.path, l.rotations)) for i := l.rotations - 1; i >= 1; i-- { oldPath, newPath := fmt.Sprintf("%s.%d", l.path, i), fmt.Sprintf("%s.%d", l.path, i+1) if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("rotate operation log: %w", err) } } if err := os.Rename(l.path, l.path+".1"); err != nil && !os.IsNotExist(err) { return fmt.Errorf("rotate operation log: %w", err) } return nil }