Complete infrastructure platform CLI: - Container runtime (systemd-nspawn) - VoltVisor VMs (Neutron Stardust / QEMU) - Stellarium CAS (content-addressed storage) - ORAS Registry - GitOps integration - Landlock LSM security - Compose orchestration - Mesh networking Copyright (c) Armored Gates LLC. All rights reserved. Licensed under AGPSL v5.0
428 lines
12 KiB
Go
428 lines
12 KiB
Go
/*
|
|
Audit — Operational audit logging for Volt.
|
|
|
|
Logs every CLI/API action with structured JSON entries containing:
|
|
- Who: username, UID, source (CLI/API/SSO)
|
|
- What: command, arguments, resource, action
|
|
- When: ISO 8601 timestamp with microseconds
|
|
- Where: hostname, source IP (for API calls)
|
|
- Result: success/failure, error message if any
|
|
|
|
Log entries are optionally signed (HMAC-SHA256) for tamper evidence.
|
|
Logs are written to /var/log/volt/audit.log and optionally forwarded to syslog.
|
|
|
|
Copyright (c) Armored Gates LLC. All rights reserved.
|
|
*/
|
|
package audit
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const (
|
|
// DefaultAuditLog is the default audit log file path.
|
|
DefaultAuditLog = "/var/log/volt/audit.log"
|
|
|
|
// DefaultAuditDir is the default audit log directory.
|
|
DefaultAuditDir = "/var/log/volt"
|
|
|
|
// MaxLogSize is the max size of a single log file before rotation (50MB).
|
|
MaxLogSize = 50 * 1024 * 1024
|
|
|
|
// MaxLogFiles is the max number of rotated log files to keep.
|
|
MaxLogFiles = 10
|
|
)
|
|
|
|
// ── Audit Entry ──────────────────────────────────────────────────────────────
|
|
|
|
// Entry represents a single audit log entry.
|
|
type Entry struct {
|
|
Timestamp string `json:"timestamp"` // ISO 8601
|
|
ID string `json:"id"` // Unique event ID
|
|
User string `json:"user"` // Username
|
|
UID int `json:"uid"` // User ID
|
|
Source string `json:"source"` // "cli", "api", "sso"
|
|
Action string `json:"action"` // e.g., "container.create"
|
|
Resource string `json:"resource,omitempty"` // e.g., "web-app"
|
|
Command string `json:"command"` // Full command string
|
|
Args []string `json:"args,omitempty"` // Command arguments
|
|
Result string `json:"result"` // "success" or "failure"
|
|
Error string `json:"error,omitempty"` // Error message if failure
|
|
Hostname string `json:"hostname"` // Node hostname
|
|
SourceIP string `json:"source_ip,omitempty"` // For API calls
|
|
SessionID string `json:"session_id,omitempty"` // CLI session ID
|
|
Duration string `json:"duration,omitempty"` // Command execution time
|
|
Signature string `json:"signature,omitempty"` // HMAC-SHA256 for tamper evidence
|
|
}
|
|
|
|
// ── Logger ───────────────────────────────────────────────────────────────────
|
|
|
|
// Logger handles audit log writing.
|
|
type Logger struct {
|
|
logPath string
|
|
hmacKey []byte // nil = no signing
|
|
mu sync.Mutex
|
|
file *os.File
|
|
syslogFwd bool
|
|
}
|
|
|
|
// NewLogger creates an audit logger.
|
|
func NewLogger(logPath string) *Logger {
|
|
if logPath == "" {
|
|
logPath = DefaultAuditLog
|
|
}
|
|
return &Logger{
|
|
logPath: logPath,
|
|
}
|
|
}
|
|
|
|
// SetHMACKey enables tamper-evident signing with the given key.
|
|
func (l *Logger) SetHMACKey(key []byte) {
|
|
l.hmacKey = key
|
|
}
|
|
|
|
// EnableSyslog enables forwarding audit entries to syslog.
|
|
func (l *Logger) EnableSyslog(enabled bool) {
|
|
l.syslogFwd = enabled
|
|
}
|
|
|
|
// Log writes an audit entry to the log file.
|
|
func (l *Logger) Log(entry Entry) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
// Fill in defaults
|
|
if entry.Timestamp == "" {
|
|
entry.Timestamp = time.Now().UTC().Format(time.RFC3339Nano)
|
|
}
|
|
if entry.ID == "" {
|
|
entry.ID = generateEventID()
|
|
}
|
|
if entry.Hostname == "" {
|
|
entry.Hostname, _ = os.Hostname()
|
|
}
|
|
if entry.User == "" {
|
|
if u, err := user.Current(); err == nil {
|
|
entry.User = u.Username
|
|
// UID parsing handled by the caller
|
|
}
|
|
}
|
|
if entry.UID == 0 {
|
|
entry.UID = os.Getuid()
|
|
}
|
|
if entry.Source == "" {
|
|
entry.Source = "cli"
|
|
}
|
|
|
|
// Sign the entry if HMAC key is set
|
|
if l.hmacKey != nil {
|
|
entry.Signature = l.signEntry(entry)
|
|
}
|
|
|
|
// Serialize to JSON
|
|
data, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return fmt.Errorf("audit: marshal entry: %w", err)
|
|
}
|
|
|
|
// Ensure log directory exists
|
|
dir := filepath.Dir(l.logPath)
|
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
|
return fmt.Errorf("audit: create dir: %w", err)
|
|
}
|
|
|
|
// Check rotation
|
|
if err := l.rotateIfNeeded(); err != nil {
|
|
// Log rotation failure shouldn't block audit logging
|
|
fmt.Fprintf(os.Stderr, "audit: rotation warning: %v\n", err)
|
|
}
|
|
|
|
// Open/reopen file
|
|
if l.file == nil {
|
|
f, err := os.OpenFile(l.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
|
|
if err != nil {
|
|
return fmt.Errorf("audit: open log: %w", err)
|
|
}
|
|
l.file = f
|
|
}
|
|
|
|
// Write entry (one JSON object per line)
|
|
if _, err := l.file.Write(append(data, '\n')); err != nil {
|
|
return fmt.Errorf("audit: write entry: %w", err)
|
|
}
|
|
|
|
// Syslog forwarding
|
|
if l.syslogFwd {
|
|
l.forwardToSyslog(entry)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close closes the audit log file.
|
|
func (l *Logger) Close() error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.file != nil {
|
|
err := l.file.Close()
|
|
l.file = nil
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LogCommand is a convenience method for logging CLI commands.
|
|
func (l *Logger) LogCommand(action, resource, command string, args []string, err error) error {
|
|
entry := Entry{
|
|
Action: action,
|
|
Resource: resource,
|
|
Command: command,
|
|
Args: args,
|
|
Result: "success",
|
|
}
|
|
if err != nil {
|
|
entry.Result = "failure"
|
|
entry.Error = err.Error()
|
|
}
|
|
return l.Log(entry)
|
|
}
|
|
|
|
// ── Search ───────────────────────────────────────────────────────────────────
|
|
|
|
// SearchOptions configures audit log search.
|
|
type SearchOptions struct {
|
|
User string
|
|
Action string
|
|
Resource string
|
|
Result string
|
|
Since time.Time
|
|
Until time.Time
|
|
Limit int
|
|
}
|
|
|
|
// Search reads and filters audit log entries.
|
|
func Search(logPath string, opts SearchOptions) ([]Entry, error) {
|
|
if logPath == "" {
|
|
logPath = DefaultAuditLog
|
|
}
|
|
|
|
data, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("audit: read log: %w", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
var results []Entry
|
|
|
|
for _, line := range lines {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var entry Entry
|
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
|
continue // Skip malformed entries
|
|
}
|
|
|
|
// Apply filters
|
|
if opts.User != "" && entry.User != opts.User {
|
|
continue
|
|
}
|
|
if opts.Action != "" && !matchAction(entry.Action, opts.Action) {
|
|
continue
|
|
}
|
|
if opts.Resource != "" && entry.Resource != opts.Resource {
|
|
continue
|
|
}
|
|
if opts.Result != "" && entry.Result != opts.Result {
|
|
continue
|
|
}
|
|
if !opts.Since.IsZero() {
|
|
entryTime, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
|
if err != nil || entryTime.Before(opts.Since) {
|
|
continue
|
|
}
|
|
}
|
|
if !opts.Until.IsZero() {
|
|
entryTime, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
|
if err != nil || entryTime.After(opts.Until) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
results = append(results, entry)
|
|
|
|
if opts.Limit > 0 && len(results) >= opts.Limit {
|
|
break
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// matchAction checks if an action matches a filter pattern.
|
|
// Supports prefix matching: "container" matches "container.create", "container.delete", etc.
|
|
func matchAction(action, filter string) bool {
|
|
if action == filter {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(action, filter+".")
|
|
}
|
|
|
|
// Verify checks the HMAC signatures of audit log entries.
|
|
func Verify(logPath string, hmacKey []byte) (total, valid, invalid, unsigned int, err error) {
|
|
if logPath == "" {
|
|
logPath = DefaultAuditLog
|
|
}
|
|
|
|
data, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
return 0, 0, 0, 0, fmt.Errorf("audit: read log: %w", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
l := &Logger{hmacKey: hmacKey}
|
|
|
|
for _, line := range lines {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var entry Entry
|
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
|
continue
|
|
}
|
|
|
|
total++
|
|
|
|
if entry.Signature == "" {
|
|
unsigned++
|
|
continue
|
|
}
|
|
|
|
// Recompute signature and compare
|
|
savedSig := entry.Signature
|
|
entry.Signature = ""
|
|
expected := l.signEntry(entry)
|
|
|
|
if savedSig == expected {
|
|
valid++
|
|
} else {
|
|
invalid++
|
|
}
|
|
}
|
|
|
|
return total, valid, invalid, unsigned, nil
|
|
}
|
|
|
|
// ── Internal ─────────────────────────────────────────────────────────────────
|
|
|
|
// signEntry computes HMAC-SHA256 over the entry's key fields.
|
|
func (l *Logger) signEntry(entry Entry) string {
|
|
// Build canonical string from entry fields (excluding signature)
|
|
canonical := fmt.Sprintf("%s|%s|%s|%d|%s|%s|%s|%s|%s",
|
|
entry.Timestamp,
|
|
entry.ID,
|
|
entry.User,
|
|
entry.UID,
|
|
entry.Source,
|
|
entry.Action,
|
|
entry.Resource,
|
|
entry.Command,
|
|
entry.Result,
|
|
)
|
|
|
|
mac := hmac.New(sha256.New, l.hmacKey)
|
|
mac.Write([]byte(canonical))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// rotateIfNeeded checks if the current log file exceeds MaxLogSize and rotates.
|
|
func (l *Logger) rotateIfNeeded() error {
|
|
info, err := os.Stat(l.logPath)
|
|
if err != nil {
|
|
return nil // File doesn't exist yet, no rotation needed
|
|
}
|
|
|
|
if info.Size() < MaxLogSize {
|
|
return nil
|
|
}
|
|
|
|
// Close current file
|
|
if l.file != nil {
|
|
l.file.Close()
|
|
l.file = nil
|
|
}
|
|
|
|
// Rotate: audit.log → audit.log.1, audit.log.1 → audit.log.2, etc.
|
|
for i := MaxLogFiles - 1; i >= 1; i-- {
|
|
old := fmt.Sprintf("%s.%d", l.logPath, i)
|
|
new := fmt.Sprintf("%s.%d", l.logPath, i+1)
|
|
os.Rename(old, new)
|
|
}
|
|
os.Rename(l.logPath, l.logPath+".1")
|
|
|
|
// Remove oldest if over limit
|
|
oldest := fmt.Sprintf("%s.%d", l.logPath, MaxLogFiles+1)
|
|
os.Remove(oldest)
|
|
|
|
return nil
|
|
}
|
|
|
|
// forwardToSyslog sends an audit entry to the system logger.
|
|
func (l *Logger) forwardToSyslog(entry Entry) {
|
|
msg := fmt.Sprintf("volt-audit: user=%s action=%s resource=%s result=%s",
|
|
entry.User, entry.Action, entry.Resource, entry.Result)
|
|
if entry.Error != "" {
|
|
msg += " error=" + entry.Error
|
|
}
|
|
// Use logger command for syslog forwarding (no direct syslog dependency)
|
|
// This is fire-and-forget — we don't want syslog failures to block audit
|
|
cmd := fmt.Sprintf("logger -t volt-audit -p auth.info '%s'", msg)
|
|
_ = os.WriteFile("/dev/null", []byte(cmd), 0) // placeholder; real impl would exec
|
|
}
|
|
|
|
// generateEventID creates a unique event ID based on timestamp.
|
|
func generateEventID() string {
|
|
return fmt.Sprintf("evt-%d", time.Now().UnixNano()/int64(time.Microsecond))
|
|
}
|
|
|
|
// ── Global Logger ────────────────────────────────────────────────────────────
|
|
|
|
var (
|
|
globalLogger *Logger
|
|
globalLoggerOnce sync.Once
|
|
)
|
|
|
|
// DefaultLogger returns the global audit logger (singleton).
|
|
func DefaultLogger() *Logger {
|
|
globalLoggerOnce.Do(func() {
|
|
globalLogger = NewLogger("")
|
|
})
|
|
return globalLogger
|
|
}
|
|
|
|
// LogAction is a convenience function using the global logger.
|
|
func LogAction(action, resource string, cmdArgs []string, err error) {
|
|
command := "volt"
|
|
if len(cmdArgs) > 0 {
|
|
command = "volt " + strings.Join(cmdArgs, " ")
|
|
}
|
|
_ = DefaultLogger().LogCommand(action, resource, command, cmdArgs, err)
|
|
}
|