feat: add diagnostics and safe reset maintenance
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package oplog_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/oplog"
|
||||
)
|
||||
|
||||
func TestLoggerRotatesAtBoundAndNeverStoresAuthenticatedURL(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state", "proxyctl.log")
|
||||
logger := oplog.New(path, 180, 2)
|
||||
for i := 0; i < 20; i++ {
|
||||
err := logger.Write(app.LogEntry{Time: time.Unix(int64(i), 0), Operation: "reset", Target: "fake", Result: "failure", Detail: "request http://alice:secret@proxy.example:8080/private failed"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, candidate := range []string{path, path + ".1", path + ".2"} {
|
||||
data, err := os.ReadFile(candidate)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) > 180 {
|
||||
t.Errorf("%s has %d bytes, max 180", filepath.Base(candidate), len(data))
|
||||
}
|
||||
text := string(data)
|
||||
if strings.Contains(text, "secret") || strings.Contains(text, "proxy.example:8080/private") {
|
||||
t.Errorf("log leaked authenticated URL: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "operation=reset") || !strings.Contains(text, "target=fake") || !strings.Contains(text, "result=failure") {
|
||||
t.Errorf("log lacks operation fields: %q", text)
|
||||
}
|
||||
}
|
||||
matches, err := filepath.Glob(path + "*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) > 3 {
|
||||
t.Fatalf("retained %d logs, want at most 3: %v", len(matches), matches)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user