projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
247
agent/internal/event/buffer.go
Normal file
247
agent/internal/event/buffer.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
|
||||
"sync"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxBufferSize = 1000 // Max events to buffer
|
||||
)
|
||||
|
||||
// Buffer handles local event buffering for offline resilience
|
||||
type Buffer struct {
|
||||
filePath string
|
||||
maxSize int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewBuffer creates a new event buffer with the specified file path
|
||||
func NewBuffer(filePath string) *Buffer {
|
||||
return &Buffer{
|
||||
filePath: filePath,
|
||||
maxSize: defaultMaxBufferSize,
|
||||
}
|
||||
}
|
||||
|
||||
// BufferEvent saves an event to the local buffer file
|
||||
func (b *Buffer) BufferEvent(event *models.SystemEvent) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Ensure event has an ID
|
||||
if event.ID == uuid.Nil {
|
||||
return fmt.Errorf("event ID cannot be nil")
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(b.filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create buffer directory: %w", err)
|
||||
}
|
||||
|
||||
// Read existing buffer
|
||||
var events []*models.SystemEvent
|
||||
if data, err := os.ReadFile(b.filePath); err == nil {
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
// If we can't unmarshal, start fresh
|
||||
events = []*models.SystemEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
// Append new event
|
||||
events = append(events, event)
|
||||
|
||||
// Desktop history survives successful fleet delivery. Seed it from the
|
||||
// pending queue on upgrade, then deduplicate retries by the event identity.
|
||||
historyErr := b.retainHistoryLocked(events)
|
||||
|
||||
// Keep only last N events if buffer too large (circular buffer)
|
||||
if len(events) > b.maxSize {
|
||||
events = events[len(events)-b.maxSize:]
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
data, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal events: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(b.filePath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write buffer file: %w", err)
|
||||
}
|
||||
|
||||
return historyErr
|
||||
}
|
||||
|
||||
const localHistoryLimit = 5000
|
||||
|
||||
func (b *Buffer) historyPath() string { return b.filePath + ".history.json" }
|
||||
|
||||
func (b *Buffer) retainHistoryLocked(pending []*models.SystemEvent) error {
|
||||
var history []*models.SystemEvent
|
||||
data, err := os.ReadFile(b.historyPath())
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read local history: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(data, &history); err != nil {
|
||||
return fmt.Errorf("decode local history: %w", err)
|
||||
}
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool, len(history))
|
||||
for _, e := range history {
|
||||
if e != nil {
|
||||
seen[e.ID] = true
|
||||
}
|
||||
}
|
||||
for _, e := range pending {
|
||||
if e != nil && !seen[e.ID] {
|
||||
history = append(history, e)
|
||||
seen[e.ID] = true
|
||||
}
|
||||
}
|
||||
if len(history) > localHistoryLimit {
|
||||
history = history[len(history)-localHistoryLimit:]
|
||||
}
|
||||
data, err = json.Marshal(history)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode local history: %w", err)
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(b.filePath), ".history-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, err = f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(f.Name(), b.historyPath()); err != nil {
|
||||
return fmt.Errorf("save local history: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadHistory returns retained events independently of the delivery queue.
|
||||
// Before the first new event after upgrade, the existing queue remains visible.
|
||||
func (b *Buffer) ReadHistory() ([]*models.SystemEvent, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
data, err := os.ReadFile(b.historyPath())
|
||||
if os.IsNotExist(err) {
|
||||
data, err = os.ReadFile(b.filePath)
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var events []*models.SystemEvent
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// GetBufferedEvents retrieves and clears the buffer atomically.
|
||||
func (b *Buffer) GetBufferedEvents() ([]*models.SystemEvent, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
var events []*models.SystemEvent
|
||||
data, err := os.ReadFile(b.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read buffer file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// ReadBufferedEvents retrieves buffered events without clearing them.
|
||||
func (b *Buffer) ReadBufferedEvents() ([]*models.SystemEvent, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Read buffer file
|
||||
var events []*models.SystemEvent
|
||||
data, err := os.ReadFile(b.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No buffer file means no events
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read buffer file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// Clear removes the current buffer file.
|
||||
func (b *Buffer) Clear() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMaxSize sets the maximum number of events to buffer
|
||||
func (b *Buffer) SetMaxSize(size int) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.maxSize = size
|
||||
}
|
||||
|
||||
// GetStats returns buffer statistics
|
||||
func (b *Buffer) GetStats() (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(b.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var events []*models.SystemEvent
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(events), nil
|
||||
}
|
||||
318
agent/internal/event/helpers.go
Normal file
318
agent/internal/event/helpers.go
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
// Package event provides event helper functions and buffering for the RedFlag agent.
|
||||
//
|
||||
// ETHOS Compliance:
|
||||
// (1) Errors are History, Not /dev/null
|
||||
// - All failures are captured as events
|
||||
// - Events include full context and metadata
|
||||
// - Events are buffered for offline scenarios
|
||||
//
|
||||
// (3) Assume Failure; Build for Resilience
|
||||
// - Best-effort event buffering (fails gracefully if buffer unavailable)
|
||||
// - Events persist to disk for recovery
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// ScannerEventType represents a scanner operation event
|
||||
type ScannerEventType string
|
||||
|
||||
const (
|
||||
// Event types for agent operations
|
||||
EventTypeAgentScan = "agent_scan"
|
||||
EventTypeAgentConfig = "agent_config"
|
||||
EventTypeAgentCheckIn = "agent_checkin"
|
||||
EventTypeAgentOffline = "agent_offline"
|
||||
)
|
||||
|
||||
// ScanResult indicates the outcome of a scan operation
|
||||
type ScanResult string
|
||||
|
||||
const (
|
||||
ScanResultSuccess ScanResult = "success"
|
||||
ScanResultFailed ScanResult = "failed"
|
||||
ScanResultTimeout ScanResult = "timeout"
|
||||
ScanResultSkipped ScanResult = "skipped"
|
||||
)
|
||||
|
||||
// BufferSystemEvent records a SystemEvent into the operational event buffer.
|
||||
// A nil buffer degrades to local-only behavior; callers should still log or
|
||||
// report command results through their normal channel.
|
||||
func BufferSystemEvent(buffer *Buffer, agentID uuid.UUID, eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
|
||||
if buffer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
evt := &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: eventType,
|
||||
EventSubtype: eventSubtype,
|
||||
Severity: severity,
|
||||
Component: component,
|
||||
Message: message,
|
||||
Metadata: metadata,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := buffer.BufferEvent(evt); err != nil {
|
||||
log.Printf("[WARNING] [agent] [event] buffer_system_event_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NewScanFailureEvent creates a SystemEvent for a failed scan operation
|
||||
//
|
||||
// Parameters:
|
||||
// - scannerName: The name of the scanner that failed (e.g., "apt", "docker")
|
||||
// - err: The error that occurred
|
||||
// - duration: How long the scan took before failing
|
||||
// - agentID: The agent UUID (empty for client-side creation without binding)
|
||||
//
|
||||
// Returns a SystemEvent ready to buffer or send to the server.
|
||||
func NewScanFailureEvent(scannerName string, err error, duration time.Duration, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
// Determine severity based on error type and scanner
|
||||
severity := models.SeverityWarning
|
||||
if isCriticalScanner(scannerName) {
|
||||
severity = models.SeverityCritical
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentScan,
|
||||
EventSubtype: models.SubtypeFailed,
|
||||
Severity: severity,
|
||||
Component: scannerName + "_scanner",
|
||||
Message: fmt.Sprintf("%s scan failed after %v: %s", scannerName, duration.Round(time.Millisecond), err.Error()),
|
||||
Metadata: map[string]interface{}{
|
||||
"scanner": scannerName,
|
||||
"error": err.Error(),
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"result": string(ScanResultFailed),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewScanSuccessEvent creates a SystemEvent for a successful scan operation
|
||||
//
|
||||
// Parameters:
|
||||
// - scannerName: The name of the scanner
|
||||
// - updateCount: Number of updates found
|
||||
// - duration: How long the scan took
|
||||
// - agentID: The agent UUID (empty for client-side creation without binding)
|
||||
func NewScanSuccessEvent(scannerName string, updateCount int, duration time.Duration, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s scan completed in %v", scannerName, duration.Round(time.Millisecond))
|
||||
if updateCount > 0 {
|
||||
message = fmt.Sprintf("%s scan found %d update(s) in %v", scannerName, updateCount, duration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentScan,
|
||||
EventSubtype: models.SubtypeSuccess,
|
||||
Severity: models.SeverityInfo,
|
||||
Component: scannerName + "_scanner",
|
||||
Message: message,
|
||||
Metadata: map[string]interface{}{
|
||||
"scanner": scannerName,
|
||||
"update_count": updateCount,
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"result": string(ScanResultSuccess),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewScanTimeoutEvent creates a SystemEvent for a scan that timed out
|
||||
func NewScanTimeoutEvent(scannerName string, duration time.Duration, timeout time.Duration, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentScan,
|
||||
EventSubtype: models.SubtypeNetworkTimeout,
|
||||
Severity: models.SeverityWarning,
|
||||
Component: scannerName + "_scanner",
|
||||
Message: fmt.Sprintf("%s scan timed out after %v (timeout: %v)", scannerName, duration.Round(time.Millisecond), timeout),
|
||||
Metadata: map[string]interface{}{
|
||||
"scanner": scannerName,
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"timeout_ms": timeout.Milliseconds(),
|
||||
"result": string(ScanResultTimeout),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewConfigSyncEvent creates a SystemEvent for a config sync operation
|
||||
func NewConfigSyncEvent(success bool, changes []string, attempt int, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
subtype := models.SubtypeSuccess
|
||||
severity := models.SeverityInfo
|
||||
message := fmt.Sprintf("Config synced successfully (attempt %d)", attempt)
|
||||
|
||||
if !success {
|
||||
subtype = models.SubtypeFailed
|
||||
severity = models.SeverityWarning
|
||||
message = fmt.Sprintf("Config sync failed (attempt %d)", attempt)
|
||||
} else if len(changes) > 0 {
|
||||
message = fmt.Sprintf("Config updated: %v (attempt %d)", changes, attempt)
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentConfig,
|
||||
EventSubtype: subtype,
|
||||
Severity: severity,
|
||||
Component: models.ComponentAgent,
|
||||
Message: message,
|
||||
Metadata: map[string]interface{}{
|
||||
"success": success,
|
||||
"changes": changes,
|
||||
"attempt": attempt,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewOfflineEvent creates a SystemEvent when the agent goes offline
|
||||
func NewOfflineEvent(reason string, lastCheckIn time.Time, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
message := "Agent went offline"
|
||||
if reason != "" {
|
||||
message = fmt.Sprintf("Agent went offline: %s", reason)
|
||||
}
|
||||
|
||||
var offlineDuration string
|
||||
if !lastCheckIn.IsZero() {
|
||||
offlineDuration = fmt.Sprintf("%v", time.Since(lastCheckIn).Round(time.Second))
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentOffline,
|
||||
EventSubtype: models.SubtypeTokenExpired, // Reusing existing subtype
|
||||
Severity: models.SeverityWarning,
|
||||
Component: models.ComponentAgent,
|
||||
Message: message,
|
||||
Metadata: map[string]interface{}{
|
||||
"reason": reason,
|
||||
"last_check_in": lastCheckIn.Format(time.RFC3339),
|
||||
"offline_duration": offlineDuration,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewReconnectionEvent creates a SystemEvent when the agent reconnects
|
||||
func NewReconnectionEvent(offlineDuration time.Duration, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentCheckIn,
|
||||
EventSubtype: models.SubtypeSuccess,
|
||||
Severity: models.SeverityInfo,
|
||||
Component: models.ComponentAgent,
|
||||
Message: fmt.Sprintf("Agent reconnected after %v offline", offlineDuration.Round(time.Second)),
|
||||
Metadata: map[string]interface{}{
|
||||
"offline_duration_ms": offlineDuration.Milliseconds(),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewCheckInEvent creates a SystemEvent for a successful check-in
|
||||
func NewCheckInEvent(hasCommands bool, commandCount int, agentID uuid.UUID) *models.SystemEvent {
|
||||
var agentIDPtr *uuid.UUID
|
||||
if agentID != uuid.Nil {
|
||||
agentIDPtr = &agentID
|
||||
}
|
||||
|
||||
subtype := models.SubtypeInfo
|
||||
message := "Check-in successful - no new commands"
|
||||
if hasCommands {
|
||||
subtype = models.SubtypeSuccess
|
||||
message = fmt.Sprintf("Check-in successful - received %d command(s)", commandCount)
|
||||
}
|
||||
|
||||
return &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: EventTypeAgentCheckIn,
|
||||
EventSubtype: subtype,
|
||||
Severity: models.SeverityInfo,
|
||||
Component: models.ComponentAgent,
|
||||
Message: message,
|
||||
Metadata: map[string]interface{}{
|
||||
"has_commands": hasCommands,
|
||||
"command_count": commandCount,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// isCriticalScanner returns true if the scanner is considered critical
|
||||
// Critical scanner failures are logged with SeverityCritical
|
||||
func isCriticalScanner(scannerName string) bool {
|
||||
// Update-related scanners that indicate potential security or update issues
|
||||
criticalScanners := map[string]bool{
|
||||
"apt": true, // Security patches
|
||||
"dnf": true, // Security patches
|
||||
"windows": true, // Security patches
|
||||
"winget": false, // Optional updates
|
||||
"docker": false, // Optional feature
|
||||
"storage": false, // Monitoring only
|
||||
"system": false, // Monitoring only
|
||||
}
|
||||
|
||||
return criticalScanners[scannerName]
|
||||
}
|
||||
56
agent/internal/event/history_test.go
Normal file
56
agent/internal/event/history_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestLocalHistorySurvivesDeliveryAndRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.json")
|
||||
buffer := NewBuffer(path)
|
||||
e := &models.SystemEvent{ID: uuid.Must(uuid.NewV4()), Message: "scan failed", Severity: "error"}
|
||||
if err := buffer.BufferEvent(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := buffer.Clear(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened := NewBuffer(path)
|
||||
history, err := reopened.ReadHistory()
|
||||
if err != nil || len(history) != 1 || history[0].ID != e.ID {
|
||||
t.Fatalf("history=%v error=%v", history, err)
|
||||
}
|
||||
if err := reopened.BufferEvent(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := reopened.GetBufferedEvents(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err = reopened.ReadHistory()
|
||||
if err != nil || len(history) != 1 {
|
||||
t.Fatalf("retried event duplicated in history: count=%d error=%v", len(history), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryFailureDoesNotDiscardDeliveryQueue(t *testing.T) {
|
||||
buffer := NewBuffer(filepath.Join(t.TempDir(), "events.json"))
|
||||
if err := os.WriteFile(buffer.historyPath(), []byte("damaged history"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &models.SystemEvent{ID: uuid.Must(uuid.NewV4()), Message: "keep this event"}
|
||||
if err := buffer.BufferEvent(e); err == nil {
|
||||
t.Fatal("history damage was silent")
|
||||
}
|
||||
pending, err := buffer.ReadBufferedEvents()
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("delivery queue lost: count=%d error=%v", len(pending), err)
|
||||
}
|
||||
data, err := os.ReadFile(buffer.historyPath())
|
||||
if err != nil || string(data) != "damaged history" {
|
||||
t.Fatal("damaged history was overwritten")
|
||||
}
|
||||
}
|
||||
166
agent/internal/event/tee_logger.go
Normal file
166
agent/internal/event/tee_logger.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// Package event provides event helper functions and buffering for the RedFlag agent.
|
||||
//
|
||||
// TeeLogger emits an ETHOS-tagged log.Printf AND a buffered SystemEvent in a
|
||||
// single call. When the internal buffer is nil (e.g. during early boot or in
|
||||
// tests), it degrades to log-only mode.
|
||||
//
|
||||
// ETHOS Compliance:
|
||||
// (1) Errors are History, Not /dev/null
|
||||
// - Every TeeLogger call produces a log line (stderr/journald)
|
||||
// - Every TeeLogger call buffers a SystemEvent for server delivery
|
||||
// - Buffer failures are logged, never silently dropped
|
||||
//
|
||||
// (3) Assume Failure; Build for Resilience
|
||||
// - Nil buffer degrades gracefully to log-only
|
||||
// - Buffer write is best-effort; log line is the durability guarantee
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// TeeLogger emits an ETHOS-tagged log.Printf AND a buffered SystemEvent
|
||||
// in a single call. When the internal buffer is nil (e.g. during early boot
|
||||
// or in test), it degrades to log-only mode.
|
||||
type TeeLogger struct {
|
||||
buffer *Buffer
|
||||
agentID uuid.UUID
|
||||
}
|
||||
|
||||
// NewTeeLogger creates a TeeLogger. Pass a nil buffer for log-only mode.
|
||||
func NewTeeLogger(buffer *Buffer, agentID uuid.UUID) *TeeLogger {
|
||||
return &TeeLogger{buffer: buffer, agentID: agentID}
|
||||
}
|
||||
|
||||
// Buffer returns the operational event buffer used by this logger.
|
||||
func (l *TeeLogger) Buffer() *Buffer {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return l.buffer
|
||||
}
|
||||
|
||||
// LogParams carries all structured fields for a dual-output event.
|
||||
type LogParams struct {
|
||||
Level string // ETHOS level tag: "INFO", "WARNING", "ERROR", "CRITICAL"
|
||||
System string // ETHOS system tag: "agent"
|
||||
Component string // ETHOS component tag: "migration", "crypto", "config"
|
||||
EventType string // SystemEvent.EventType
|
||||
EventSubtype string // SystemEvent.EventSubtype
|
||||
Severity string // SystemEvent.Severity
|
||||
ServerComponent string // SystemEvent.Component
|
||||
Message string // Human-readable message
|
||||
Metadata map[string]interface{} // Structured key-value pairs
|
||||
}
|
||||
|
||||
// Log is the core dual-output method.
|
||||
func (l *TeeLogger) Log(params LogParams) {
|
||||
// Nil receiver: log-only mode (no buffer, no agent ID).
|
||||
if l == nil {
|
||||
log.Printf("[%s] [%s] [%s] %s", params.Level, params.System, params.Component, params.Message)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. ETHOS-tagged log line
|
||||
log.Printf("[%s] [%s] [%s] %s", params.Level, params.System, params.Component, params.Message)
|
||||
|
||||
// 2. Buffer SystemEvent (best-effort, nil-safe)
|
||||
if l.buffer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var agentIDPtr *uuid.UUID
|
||||
if l.agentID != uuid.Nil {
|
||||
agentIDPtr = &l.agentID
|
||||
}
|
||||
|
||||
evt := &models.SystemEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentIDPtr,
|
||||
EventType: params.EventType,
|
||||
EventSubtype: params.EventSubtype,
|
||||
Severity: params.Severity,
|
||||
Component: params.ServerComponent,
|
||||
Message: params.Message,
|
||||
Metadata: params.Metadata,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := l.buffer.BufferEvent(evt); err != nil {
|
||||
log.Printf("[WARNING] [agent] [event] tee_buffer_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Info emits an INFO-level dual-output event.
|
||||
func (l *TeeLogger) Info(system, component, serverComponent, message string, metadata map[string]interface{}) {
|
||||
l.Log(LogParams{
|
||||
Level: "INFO", System: system, Component: component,
|
||||
EventType: deriveEventType(component), EventSubtype: models.SubtypeInfo,
|
||||
Severity: models.SeverityInfo, ServerComponent: serverComponent,
|
||||
Message: message, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// Warning emits a WARNING-level dual-output event.
|
||||
func (l *TeeLogger) Warning(system, component, serverComponent, message string, metadata map[string]interface{}) {
|
||||
l.Log(LogParams{
|
||||
Level: "WARNING", System: system, Component: component,
|
||||
EventType: deriveEventType(component), EventSubtype: models.SubtypeWarning,
|
||||
Severity: models.SeverityWarning, ServerComponent: serverComponent,
|
||||
Message: message, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// Error emits an ERROR-level dual-output event.
|
||||
func (l *TeeLogger) Error(system, component, serverComponent, message string, metadata map[string]interface{}) {
|
||||
l.Log(LogParams{
|
||||
Level: "ERROR", System: system, Component: component,
|
||||
EventType: deriveEventType(component), EventSubtype: models.SubtypeFailed,
|
||||
Severity: models.SeverityError, ServerComponent: serverComponent,
|
||||
Message: message, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// Critical emits a CRITICAL-level dual-output event.
|
||||
func (l *TeeLogger) Critical(system, component, serverComponent, message string, metadata map[string]interface{}) {
|
||||
l.Log(LogParams{
|
||||
Level: "CRITICAL", System: system, Component: component,
|
||||
EventType: deriveEventType(component), EventSubtype: models.SubtypeCritical,
|
||||
Severity: models.SeverityCritical, ServerComponent: serverComponent,
|
||||
Message: message, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// deriveEventType maps an ETHOS component tag to a SystemEvent.EventType.
|
||||
// Callers can always override by using Log() directly with explicit params.
|
||||
func deriveEventType(component string) string {
|
||||
switch component {
|
||||
case "migration":
|
||||
return models.EventTypeAgentMigration
|
||||
case "crypto":
|
||||
return models.EventTypeAgentCrypto
|
||||
case "config":
|
||||
return models.EventTypeAgentConfig
|
||||
case "docker":
|
||||
return models.EventTypeAgentDocker
|
||||
case "installer":
|
||||
return models.EventTypeAgentInstall
|
||||
case "kernel":
|
||||
return models.EventTypeAgentStartup
|
||||
case "acknowledgment":
|
||||
return models.EventTypeAgentCheckIn
|
||||
case "receipt":
|
||||
return models.EventTypeAgentCheckIn
|
||||
case "localapi":
|
||||
return models.EventTypeAgentStartup
|
||||
case "loop":
|
||||
return models.EventTypeAgentStartup
|
||||
default:
|
||||
return models.EventTypeError
|
||||
}
|
||||
}
|
||||
169
agent/internal/event/tee_logger_test.go
Normal file
169
agent/internal/event/tee_logger_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestTeeLoggerNilBuffer(t *testing.T) {
|
||||
// TeeLogger with nil buffer should not panic
|
||||
logger := NewTeeLogger(nil, uuid.Nil)
|
||||
|
||||
// Should not panic — log-only mode
|
||||
logger.Info("agent", "test", "test_component", "test message", nil)
|
||||
logger.Warning("agent", "test", "test_component", "test warning", nil)
|
||||
logger.Error("agent", "test", "test_component", "test error", nil)
|
||||
logger.Critical("agent", "test", "test_component", "test critical", nil)
|
||||
}
|
||||
|
||||
func TestTeeLoggerNilAgentID(t *testing.T) {
|
||||
// TeeLogger with Nil agent ID should produce events with nil AgentID
|
||||
buffer := NewBuffer(t.TempDir() + "/events.json")
|
||||
logger := NewTeeLogger(buffer, uuid.Nil)
|
||||
|
||||
logger.Info("agent", "test", "test_component", "test message", nil)
|
||||
|
||||
events, err := buffer.GetBufferedEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBufferedEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
if events[0].AgentID != nil {
|
||||
t.Errorf("expected nil AgentID, got %v", events[0].AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeeLoggerEventFields(t *testing.T) {
|
||||
buffer := NewBuffer(t.TempDir() + "/events.json")
|
||||
agentID := uuid.Must(uuid.NewV4())
|
||||
logger := NewTeeLogger(buffer, agentID)
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
}
|
||||
logger.Warning("agent", "crypto", "pubkey", "key fetch failed", metadata)
|
||||
|
||||
events, err := buffer.GetBufferedEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBufferedEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
|
||||
evt := events[0]
|
||||
if evt.AgentID == nil || *evt.AgentID != agentID {
|
||||
t.Errorf("expected AgentID %v, got %v", agentID, evt.AgentID)
|
||||
}
|
||||
if evt.EventType != models.EventTypeAgentCrypto {
|
||||
t.Errorf("expected EventType %q, got %q", models.EventTypeAgentCrypto, evt.EventType)
|
||||
}
|
||||
if evt.EventSubtype != models.SubtypeWarning {
|
||||
t.Errorf("expected EventSubtype %q, got %q", models.SubtypeWarning, evt.EventSubtype)
|
||||
}
|
||||
if evt.Severity != models.SeverityWarning {
|
||||
t.Errorf("expected Severity %q, got %q", models.SeverityWarning, evt.Severity)
|
||||
}
|
||||
if evt.Component != "pubkey" {
|
||||
t.Errorf("expected Component %q, got %q", "pubkey", evt.Component)
|
||||
}
|
||||
if evt.Message != "key fetch failed" {
|
||||
t.Errorf("expected Message %q, got %q", "key fetch failed", evt.Message)
|
||||
}
|
||||
if evt.Metadata["key1"] != "value1" {
|
||||
t.Errorf("expected metadata key1=value1, got %v", evt.Metadata["key1"])
|
||||
}
|
||||
// JSON round-trip converts int to float64
|
||||
if int(evt.Metadata["key2"].(float64)) != 42 {
|
||||
t.Errorf("expected metadata key2=42, got %v", evt.Metadata["key2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeeLoggerConvenienceMethods(t *testing.T) {
|
||||
buffer := NewBuffer(t.TempDir() + "/events.json")
|
||||
logger := NewTeeLogger(buffer, uuid.Must(uuid.NewV4()))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
call func()
|
||||
expectedLevel string
|
||||
expectedSubtype string
|
||||
expectedSeverity string
|
||||
}{
|
||||
{"Info", func() { logger.Info("agent", "config", "config", "loaded", nil) }, "INFO", models.SubtypeInfo, models.SeverityInfo},
|
||||
{"Warning", func() { logger.Warning("agent", "config", "config", "stale", nil) }, "WARNING", models.SubtypeWarning, models.SeverityWarning},
|
||||
{"Error", func() { logger.Error("agent", "config", "config", "failed", nil) }, "ERROR", models.SubtypeFailed, models.SeverityError},
|
||||
{"Critical", func() { logger.Critical("agent", "config", "config", "corrupt", nil) }, "CRITICAL", models.SubtypeCritical, models.SeverityCritical},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.call()
|
||||
})
|
||||
}
|
||||
|
||||
events, err := buffer.GetBufferedEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBufferedEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != len(tests) {
|
||||
t.Fatalf("expected %d events, got %d", len(tests), len(events))
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
evt := events[i]
|
||||
if evt.EventSubtype != tt.expectedSubtype {
|
||||
t.Errorf("%s: expected EventSubtype %q, got %q", tt.name, tt.expectedSubtype, evt.EventSubtype)
|
||||
}
|
||||
if evt.Severity != tt.expectedSeverity {
|
||||
t.Errorf("%s: expected Severity %q, got %q", tt.name, tt.expectedSeverity, evt.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveEventType(t *testing.T) {
|
||||
tests := []struct {
|
||||
component string
|
||||
expected string
|
||||
}{
|
||||
{"migration", models.EventTypeAgentMigration},
|
||||
{"crypto", models.EventTypeAgentCrypto},
|
||||
{"config", models.EventTypeAgentConfig},
|
||||
{"docker", models.EventTypeAgentDocker},
|
||||
{"installer", models.EventTypeAgentInstall},
|
||||
{"unknown", models.EventTypeError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.component, func(t *testing.T) {
|
||||
got := deriveEventType(tt.component)
|
||||
if got != tt.expected {
|
||||
t.Errorf("deriveEventType(%q) = %q, want %q", tt.component, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeeLoggerMetadataNil(t *testing.T) {
|
||||
buffer := NewBuffer(t.TempDir() + "/events.json")
|
||||
logger := NewTeeLogger(buffer, uuid.Must(uuid.NewV4()))
|
||||
|
||||
// Should not panic with nil metadata
|
||||
logger.Info("agent", "test", "test_component", "test message", nil)
|
||||
|
||||
events, err := buffer.GetBufferedEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("GetBufferedEvents failed: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
if events[0].Metadata != nil {
|
||||
t.Errorf("expected nil metadata, got %v", events[0].Metadata)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue