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
494
agent/internal/orchestrator/command_handler.go
Normal file
494
agent/internal/orchestrator/command_handler.go
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
package orchestrator
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/crypto"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/logging"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// ConfirmedTracker is a disk-persisted set of command IDs the server has confirmed
|
||||
// as completed (via ReportLog). Used to distinguish between "command re-sent by
|
||||
// server before agent confirmed" vs "command truly duplicate."
|
||||
type ConfirmedTracker struct {
|
||||
confirmed map[string]time.Time
|
||||
mu sync.RWMutex
|
||||
filePath string
|
||||
}
|
||||
|
||||
// NewConfirmedTracker creates a tracker that persists state under statePath/confirmed_completed.json.
|
||||
func NewConfirmedTracker(statePath string) *ConfirmedTracker {
|
||||
return &ConfirmedTracker{
|
||||
confirmed: make(map[string]time.Time),
|
||||
filePath: filepath.Join(statePath, "confirmed_completed.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// Load restores confirmed completions from disk. Missing file is not an error (fresh start).
|
||||
func (t *ConfirmedTracker) Load() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(t.filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read confirmed_completed: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var loaded map[string]time.Time
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return fmt.Errorf("parse confirmed_completed: %w", err)
|
||||
}
|
||||
t.confirmed = loaded
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists the confirmed set to disk.
|
||||
func (t *ConfirmedTracker) Save() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(t.filePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create confirmed dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(t.confirmed, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal confirmed_completed: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(t.filePath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write confirmed_completed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add records that the server confirmed a command as completed. Safe to call
|
||||
// multiple times with the same ID — duplicates are a no-op (idempotent, ETHOS #4).
|
||||
func (t *ConfirmedTracker) Add(commandID string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, exists := t.confirmed[commandID]; exists {
|
||||
return
|
||||
}
|
||||
t.confirmed[commandID] = time.Now().UTC()
|
||||
}
|
||||
|
||||
// GetPending returns the current confirmed set as a slice (snapshot — safe to mutate).
|
||||
func (t *ConfirmedTracker) GetPending() []string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
ids := make([]string, 0, len(t.confirmed))
|
||||
for id := range t.confirmed {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Confirm removes the IDs the server confirmed. IDs not in the confirmed
|
||||
// set are silently ignored — server might confirm an ID we already dropped after
|
||||
// a successful prior round-trip.
|
||||
func (t *ConfirmedTracker) Confirm(commandIDs []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, id := range commandIDs {
|
||||
delete(t.confirmed, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the current size of the confirmed set.
|
||||
func (t *ConfirmedTracker) Len() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.confirmed)
|
||||
}
|
||||
|
||||
const (
|
||||
// keyRefreshInterval is how often the agent proactively re-checks the server's primary key
|
||||
keyRefreshInterval = 6 * time.Hour
|
||||
// commandMaxAge is the maximum age of a signed command (F-4 fix: reduced from 24h to 4h)
|
||||
commandMaxAge = 4 * time.Hour
|
||||
// commandClockSkew is the allowed future clock skew for signed commands
|
||||
commandClockSkew = 5 * time.Minute
|
||||
)
|
||||
|
||||
// CommandHandler handles command processing with signature verification
|
||||
type CommandHandler struct {
|
||||
verifier *crypto.CommandVerifier
|
||||
securityLogger *logging.SecurityLogger
|
||||
keyCache map[string]ed25519.PublicKey // key_id -> public key
|
||||
keyCacheMu sync.RWMutex
|
||||
executedIDs map[string]time.Time // cmd UUID -> execution time (F-2 fix: dedup)
|
||||
executedIDsMu sync.Mutex
|
||||
executedIDsPath string // Migration 033 §5: disk-persisted dedup
|
||||
// confirmedCompleted tracks command IDs the server has confirmed as completed
|
||||
// (via ReceiptConfirmedIDs/AcknowledgedIDs in response). Commands in this set
|
||||
// are NOT rejected as duplicates even if they appear in the executed set,
|
||||
// because the server may not have processed the ReportLog() yet.
|
||||
confirmedCompleted map[string]time.Time // cmd UUID -> confirmation time
|
||||
confirmedCompletedMu sync.Mutex
|
||||
confirmedCompletedPath string // disk-persisted confirmation set
|
||||
lastKeyRefresh time.Time
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// CommandSigningConfig holds configuration for command signing
|
||||
type CommandSigningConfig struct {
|
||||
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_COMMAND_SIGNING_ENABLED" default:"true"`
|
||||
EnforcementMode string `json:"enforcement_mode" env:"REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE" default:"strict"`
|
||||
}
|
||||
|
||||
// NewCommandHandler creates a new command handler. stateDir is the agent's persistent
|
||||
// state directory; passing an empty string disables disk-persisted dedup (test path).
|
||||
func NewCommandHandler(cfg *config.Config, stateDir string, securityLogger *logging.SecurityLogger, logger *log.Logger) (*CommandHandler, error) {
|
||||
handler := &CommandHandler{
|
||||
securityLogger: securityLogger,
|
||||
logger: logger,
|
||||
verifier: crypto.NewCommandVerifier(),
|
||||
keyCache: make(map[string]ed25519.PublicKey),
|
||||
executedIDs: make(map[string]time.Time),
|
||||
confirmedCompleted: make(map[string]time.Time),
|
||||
confirmedCompletedMu: sync.Mutex{},
|
||||
confirmedCompletedPath: "",
|
||||
}
|
||||
if stateDir != "" {
|
||||
handler.executedIDsPath = filepath.Join(stateDir, "executed_commands.json")
|
||||
handler.confirmedCompletedPath = filepath.Join(stateDir, "confirmed_completed.json")
|
||||
}
|
||||
|
||||
// Migration 033 §5: rebuild dedup set from disk so a restart can't re-execute a
|
||||
// command issued within commandMaxAge. Missing file is a fresh start, not an error.
|
||||
if handler.executedIDsPath != "" {
|
||||
if err := handler.loadExecutedIDs(); err != nil {
|
||||
logger.Printf("[WARNING] [agent] [cmd_handler] load_executed_ids_failed path=%q error=%v", handler.executedIDsPath, err)
|
||||
} else {
|
||||
logger.Printf("[INFO] [agent] [cmd_handler] executed_ids_loaded count=%d path=%q", len(handler.executedIDs), handler.executedIDsPath)
|
||||
// Trim anything that's already aged out — keeps the file from growing forever.
|
||||
handler.CleanupExecutedIDs()
|
||||
}
|
||||
}
|
||||
|
||||
// Load confirmed completion tracker (server-acked commands)
|
||||
if handler.confirmedCompletedPath != "" {
|
||||
if err := handler.loadConfirmedCompleted(); err != nil {
|
||||
logger.Printf("[WARNING] [agent] [cmd_handler] load_confirmed_completed_failed path=%q error=%v", handler.confirmedCompletedPath, err)
|
||||
} else {
|
||||
logger.Printf("[INFO] [agent] [cmd_handler] confirmed_completed_loaded count=%d", len(handler.confirmedCompleted))
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-load cached public key if command signing is enabled
|
||||
if cfg.CommandSigning.Enabled {
|
||||
if pubKey, err := crypto.LoadCachedPublicKey(); err == nil {
|
||||
// Store under empty key_id for backward-compat lookup
|
||||
handler.keyCacheMu.Lock()
|
||||
handler.keyCache[""] = pubKey
|
||||
handler.keyCacheMu.Unlock()
|
||||
logger.Printf("[INFO] [agent] [cmd_handler] primary_public_key_loaded")
|
||||
} else {
|
||||
logger.Printf("[WARNING] [agent] [cmd_handler] primary_key_not_cached error=\"%v\"", err)
|
||||
}
|
||||
}
|
||||
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
// loadExecutedIDs restores the dedup set from disk. Caller holds no lock.
|
||||
func (h *CommandHandler) loadExecutedIDs() error {
|
||||
if _, err := os.Stat(h.executedIDsPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(h.executedIDsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var loaded map[string]time.Time
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
h.executedIDsMu.Lock()
|
||||
h.executedIDs = loaded
|
||||
h.executedIDsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadConfirmedCompleted restores the server-confirmed completion set from disk.
|
||||
// Called once during construction. Caller holds no lock.
|
||||
func (h *CommandHandler) loadConfirmedCompleted() error {
|
||||
if _, err := os.Stat(h.confirmedCompletedPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(h.confirmedCompletedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read confirmed_completed: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var loaded map[string]time.Time
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return fmt.Errorf("parse confirmed_completed: %w", err)
|
||||
}
|
||||
h.confirmedCompletedMu.Lock()
|
||||
h.confirmedCompleted = loaded
|
||||
h.confirmedCompletedMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveConfirmedCompleted persists the server-confirmed completion set to disk.
|
||||
// Caller MUST hold confirmedCompletedMu.
|
||||
func (h *CommandHandler) saveConfirmedCompletedLocked() error {
|
||||
if h.confirmedCompletedPath == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(h.confirmedCompletedPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(h.confirmedCompleted, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
tmp := h.confirmedCompletedPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, h.confirmedCompletedPath); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveExecutedIDs persists the current dedup set. Caller MUST hold executedIDsMu.
|
||||
// Errors are logged by the caller — best-effort persistence; the in-memory set is
|
||||
// the authoritative source within a single process lifetime.
|
||||
func (h *CommandHandler) saveExecutedIDsLocked() error {
|
||||
if h.executedIDsPath == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(h.executedIDsPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(h.executedIDs, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
tmp := h.executedIDsPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, h.executedIDsPath); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getKeyForCommand returns the appropriate public key for verifying a command.
|
||||
// Uses key_id-aware lookup with lazy fetch for unknown keys.
|
||||
func (h *CommandHandler) getKeyForCommand(cmd client.Command, serverURL string) (ed25519.PublicKey, error) {
|
||||
keyID := cmd.KeyID
|
||||
|
||||
// Check in-memory cache first
|
||||
h.keyCacheMu.RLock()
|
||||
if key, ok := h.keyCache[keyID]; ok {
|
||||
h.keyCacheMu.RUnlock()
|
||||
return key, nil
|
||||
}
|
||||
h.keyCacheMu.RUnlock()
|
||||
|
||||
// Not in memory — check disk cache via CheckKeyRotation
|
||||
key, isNew, err := h.verifier.CheckKeyRotation(keyID, serverURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve key %q: %w", keyID, err)
|
||||
}
|
||||
|
||||
if isNew {
|
||||
h.logger.Printf("[INFO] [agent] [cmd_handler] new_signing_key_cached key_id=%q", keyID)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogKeyRotationDetected(keyID)
|
||||
}
|
||||
}
|
||||
|
||||
// Store in memory cache
|
||||
h.keyCacheMu.Lock()
|
||||
h.keyCache[keyID] = key
|
||||
h.keyCacheMu.Unlock()
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ProcessCommand processes a command with signature verification
|
||||
func (h *CommandHandler) ProcessCommand(cmd client.Command, cfg *config.Config, agentID uuid.UUID) error {
|
||||
// F-2 fix: Check deduplication BEFORE verification.
|
||||
// Persistence is implemented (see loadExecutedIDs/saveExecutedIDsLocked, Migration 033 §5):
|
||||
// the set survives restarts within commandMaxAge.
|
||||
h.executedIDsMu.Lock()
|
||||
if execTime, found := h.executedIDs[cmd.ID]; found {
|
||||
h.executedIDsMu.Unlock()
|
||||
h.logger.Printf("[WARNING] [agent] [cmd_handler] duplicate_command_rejected command_id=%q already_executed_at=%v", cmd.ID, execTime)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogCommandVerificationFailure(cmd.ID, fmt.Sprintf("duplicate command rejected, already executed at %v", execTime))
|
||||
}
|
||||
return fmt.Errorf("duplicate command %s rejected, already executed at %v", cmd.ID, execTime)
|
||||
}
|
||||
h.executedIDsMu.Unlock()
|
||||
|
||||
signingCfg := cfg.CommandSigning
|
||||
|
||||
if !signingCfg.Enabled {
|
||||
if cmd.Signature != "" {
|
||||
h.logger.Printf("[INFO] [agent] [cmd_handler] command_has_signature_but_signing_disabled command_id=%q", cmd.ID)
|
||||
}
|
||||
h.markExecuted(cmd.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve the correct public key for this command
|
||||
pubKey, err := h.getKeyForCommand(cmd, cfg.ServerURL)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] [agent] [cmd_handler] key_resolution_failed command_id=%q error=%q", cmd.ID, err)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogCommandVerificationFailure(cmd.ID, "key resolution failed: "+err.Error())
|
||||
}
|
||||
if signingCfg.EnforcementMode == "strict" {
|
||||
return fmt.Errorf("command verification failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
verifyFunc := func() error {
|
||||
if cmd.SignedAt != nil {
|
||||
// New format: timestamp-aware verification
|
||||
return h.verifier.VerifyCommandWithTimestamp(cmd, pubKey, commandMaxAge, commandClockSkew)
|
||||
}
|
||||
// Old format: no timestamp (backward compat)
|
||||
return h.verifier.VerifyCommand(cmd, pubKey)
|
||||
}
|
||||
|
||||
switch signingCfg.EnforcementMode {
|
||||
case "strict":
|
||||
if cmd.Signature == "" {
|
||||
h.logger.Printf("[ERROR] [agent] [cmd_handler] command_not_signed command_id=%q", cmd.ID)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogCommandVerificationFailure(cmd.ID, "missing signature")
|
||||
}
|
||||
return fmt.Errorf("command verification failed: strict enforcement requires signed commands")
|
||||
}
|
||||
if err := verifyFunc(); err != nil {
|
||||
h.logger.Printf("[ERROR] [agent] [cmd_handler] command_verification_failed command_id=%q error=%q", cmd.ID, err)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogCommandVerificationFailure(cmd.ID, err.Error())
|
||||
}
|
||||
return fmt.Errorf("command verification failed: %w", err)
|
||||
}
|
||||
h.logger.Printf("[INFO] [agent] [cmd_handler] command_verified command_id=%q", cmd.ID)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogCommandVerificationSuccess(cmd.ID)
|
||||
}
|
||||
h.markExecuted(cmd.ID)
|
||||
case "warning":
|
||||
if cmd.Signature != "" {
|
||||
if err := verifyFunc(); err != nil {
|
||||
h.logger.Printf("[WARNING] [agent] [cmd_handler] verification_failed_warning_mode command_id=%q error=%q", cmd.ID, err)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogCommandVerificationFailure(cmd.ID, err.Error())
|
||||
}
|
||||
} else {
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogCommandVerificationSuccess(cmd.ID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
h.logger.Printf("[WARNING] [agent] [cmd_handler] unsigned_command_warning_mode command_id=%q", cmd.ID)
|
||||
}
|
||||
h.markExecuted(cmd.ID)
|
||||
// "disabled" or any other value: skip verification
|
||||
default:
|
||||
h.markExecuted(cmd.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// markExecuted records a command ID in the deduplication set (F-2 fix).
|
||||
// Persists the updated set to disk so a restart-within-commandMaxAge cannot re-execute
|
||||
// the same command (Migration 033 §5).
|
||||
func (h *CommandHandler) markExecuted(cmdID string) {
|
||||
h.executedIDsMu.Lock()
|
||||
h.executedIDs[cmdID] = time.Now().UTC()
|
||||
if err := h.saveExecutedIDsLocked(); err != nil {
|
||||
h.logger.Printf("[ERROR] [agent] [cmd_handler] persist_executed_ids_failed cmd=%s error=%v", cmdID, err)
|
||||
}
|
||||
h.executedIDsMu.Unlock()
|
||||
}
|
||||
|
||||
// CleanupExecutedIDs evicts entries older than commandMaxAge from the dedup set.
|
||||
// Should be called when ShouldRefreshKey() fires (every 6h). Persists the trimmed
|
||||
// set if anything was evicted.
|
||||
func (h *CommandHandler) CleanupExecutedIDs() {
|
||||
h.executedIDsMu.Lock()
|
||||
defer h.executedIDsMu.Unlock()
|
||||
|
||||
cutoff := time.Now().UTC().Add(-commandMaxAge)
|
||||
evicted := 0
|
||||
for id, execTime := range h.executedIDs {
|
||||
if execTime.Before(cutoff) {
|
||||
delete(h.executedIDs, id)
|
||||
evicted++
|
||||
}
|
||||
}
|
||||
if evicted > 0 {
|
||||
h.logger.Printf("[INFO] [agent] [cmd_handler] cleanup_executed_ids evicted=%d remaining=%d", evicted, len(h.executedIDs))
|
||||
if err := h.saveExecutedIDsLocked(); err != nil {
|
||||
h.logger.Printf("[ERROR] [agent] [cmd_handler] persist_after_cleanup_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshPrimaryKey proactively re-fetches the server's primary key.
|
||||
// Should be called every keyRefreshInterval to detect rotations early.
|
||||
func (h *CommandHandler) RefreshPrimaryKey(serverURL string) error {
|
||||
h.logger.Printf("[INFO] [agent] [cmd_handler] refreshing_primary_key")
|
||||
pubKey, err := crypto.FetchAndCacheServerPublicKey(serverURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh primary key: %w", err)
|
||||
}
|
||||
|
||||
h.keyCacheMu.Lock()
|
||||
h.keyCache[""] = pubKey
|
||||
h.keyCacheMu.Unlock()
|
||||
h.lastKeyRefresh = time.Now()
|
||||
|
||||
h.logger.Printf("[INFO] [agent] [cmd_handler] primary_key_refreshed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShouldRefreshKey returns true if enough time has passed to warrant a proactive key refresh
|
||||
func (h *CommandHandler) ShouldRefreshKey() bool {
|
||||
return time.Since(h.lastKeyRefresh) >= keyRefreshInterval
|
||||
}
|
||||
|
||||
// UpdateServerPublicKey updates the primary cached public key (kept for backward compat)
|
||||
func (h *CommandHandler) UpdateServerPublicKey(serverURL string) error {
|
||||
return h.RefreshPrimaryKey(serverURL)
|
||||
}
|
||||
575
agent/internal/orchestrator/docker_scanner.go
Normal file
575
agent/internal/orchestrator/docker_scanner.go
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// DockerScanner scans for Docker image updates
|
||||
type DockerScanner struct {
|
||||
client *dockerclient.Client
|
||||
registryClient *RegistryClient
|
||||
}
|
||||
|
||||
// NewDockerScanner creates a new Docker scanner
|
||||
func NewDockerScanner() (*DockerScanner, error) {
|
||||
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DockerScanner{
|
||||
client: cli,
|
||||
registryClient: NewRegistryClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsAvailable checks if Docker is available on this system
|
||||
func (s *DockerScanner) IsAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if s.client != nil {
|
||||
_, err := s.client.Ping(context.Background())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan collects Docker image info and returns UpdateReportItems with full data in Metadata
|
||||
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var items []client.UpdateReportItem
|
||||
seenImages := make(map[string]bool)
|
||||
|
||||
for _, c := range containers {
|
||||
imageName := c.Image
|
||||
if seenImages[imageName] {
|
||||
continue
|
||||
}
|
||||
seenImages[imageName] = true
|
||||
|
||||
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(imageName, ":")
|
||||
baseImage := parts[0]
|
||||
currentTag := "latest"
|
||||
if len(parts) > 1 {
|
||||
currentTag = parts[1]
|
||||
}
|
||||
|
||||
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
|
||||
|
||||
localDigest := imageInspect.ID
|
||||
localShortDigest := ""
|
||||
if len(localDigest) > 7 {
|
||||
parts := strings.SplitN(localDigest, ":", 2)
|
||||
if len(parts) == 2 && len(parts[1]) >= 12 {
|
||||
localShortDigest = parts[1][:12]
|
||||
}
|
||||
}
|
||||
|
||||
remoteShortDigest := ""
|
||||
if len(remoteDigest) > 7 {
|
||||
parts := strings.SplitN(remoteDigest, ":", 2)
|
||||
if len(parts) == 2 && len(parts[1]) >= 12 {
|
||||
remoteShortDigest = parts[1][:12]
|
||||
}
|
||||
}
|
||||
|
||||
severity := "low"
|
||||
if hasUpdate {
|
||||
severity = "moderate"
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
if imageInspect.Config != nil {
|
||||
labels = imageInspect.Config.Labels
|
||||
}
|
||||
|
||||
sizeBytes := int64(0)
|
||||
if len(imageInspect.RootFS.Layers) > 0 {
|
||||
sizeBytes = imageInspect.Size
|
||||
}
|
||||
|
||||
createdAt := imageInspect.Created
|
||||
if createdAt == "" {
|
||||
createdAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "docker_image",
|
||||
PackageName: imageName,
|
||||
PackageDescription: fmt.Sprintf("Docker image %s:%s", imageName, currentTag),
|
||||
CurrentVersion: localShortDigest,
|
||||
AvailableVersion: remoteShortDigest,
|
||||
Severity: severity,
|
||||
RepositorySource: baseImage,
|
||||
SizeBytes: sizeBytes,
|
||||
Metadata: map[string]interface{}{
|
||||
"image_name": imageName,
|
||||
"image_tag": currentTag,
|
||||
"image_id": localShortDigest,
|
||||
"latest_image_id": remoteShortDigest,
|
||||
"has_update": hasUpdate,
|
||||
"repository": baseImage,
|
||||
"size_bytes": sizeBytes,
|
||||
"created_at": createdAt,
|
||||
"labels": labels,
|
||||
"container_id": c.ID[:12],
|
||||
"container_names": c.Names,
|
||||
"container_state": c.State,
|
||||
"image_created": imageInspect.Created,
|
||||
"local_full_digest": localDigest,
|
||||
"remote_digest": remoteDigest,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *DockerScanner) Name() string {
|
||||
return "Docker Image Scanner"
|
||||
}
|
||||
|
||||
// ScanContainers returns container-level data for enriched Docker reports.
|
||||
func (s *DockerScanner) ScanContainers() ([]client.DockerReportContainer, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var result []client.DockerReportContainer
|
||||
for _, c := range containers {
|
||||
stackName := ""
|
||||
if v, ok := c.Labels["com.docker.stack.namespace"]; ok {
|
||||
stackName = v
|
||||
}
|
||||
if v, ok := c.Labels["com.docker.compose.project"]; ok && stackName == "" {
|
||||
stackName = v
|
||||
}
|
||||
|
||||
health := ""
|
||||
if c.State == "running" {
|
||||
inspect, err := s.client.ContainerInspect(ctx, c.ID)
|
||||
if err == nil && inspect.State != nil && inspect.State.Health != nil {
|
||||
health = inspect.State.Health.Status
|
||||
}
|
||||
}
|
||||
|
||||
name := ""
|
||||
if len(c.Names) > 0 {
|
||||
name = strings.TrimPrefix(c.Names[0], "/")
|
||||
}
|
||||
|
||||
ports := ""
|
||||
for _, p := range c.Ports {
|
||||
if p.PublicPort > 0 {
|
||||
ports += fmt.Sprintf("%s:%d->%d/%s ", p.IP, p.PublicPort, p.PrivatePort, p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, client.DockerReportContainer{
|
||||
ContainerID: c.ID[:12],
|
||||
Name: name,
|
||||
Image: c.Image,
|
||||
ImageID: c.ImageID,
|
||||
State: c.State,
|
||||
Health: health,
|
||||
StackName: stackName,
|
||||
Ports: strings.TrimSpace(ports),
|
||||
CreatedAt: c.Created,
|
||||
Labels: c.Labels,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ScanStacks aggregates containers into compose stacks.
|
||||
func (s *DockerScanner) ScanStacks(containers []client.DockerReportContainer) []client.DockerReportStack {
|
||||
stackMap := make(map[string]*client.DockerReportStack)
|
||||
for _, c := range containers {
|
||||
if c.StackName == "" {
|
||||
continue
|
||||
}
|
||||
s, ok := stackMap[c.StackName]
|
||||
if !ok {
|
||||
s = &client.DockerReportStack{Name: c.StackName}
|
||||
stackMap[c.StackName] = s
|
||||
}
|
||||
s.ContainerCount++
|
||||
if c.State == "running" {
|
||||
s.RunningCount++
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]client.DockerReportStack, 0, len(stackMap))
|
||||
for _, s := range stackMap {
|
||||
result = append(result, *s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetEngineVersion returns the Docker engine version string.
|
||||
func (s *DockerScanner) GetEngineVersion() string {
|
||||
ctx := context.Background()
|
||||
v, err := s.client.ServerVersion(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v.Version
|
||||
}
|
||||
|
||||
// Close closes the Docker client
|
||||
func (s *DockerScanner) Close() error {
|
||||
if s.client != nil {
|
||||
return s.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkForUpdate checks if a newer image version is available by comparing digests
|
||||
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
|
||||
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [docker] registry_check_failed image=%s:%s error=%v", imageName, tag, err)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return currentID != remoteDigest, remoteDigest
|
||||
}
|
||||
|
||||
// --- Registry Client ---
|
||||
|
||||
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
|
||||
type RegistryClient struct {
|
||||
httpClient *http.Client
|
||||
cache *manifestCache
|
||||
}
|
||||
|
||||
// manifestCache stores registry responses to avoid hitting rate limits
|
||||
type manifestCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
digest string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
|
||||
type ManifestResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// DockerHubTokenResponse represents the authentication token response from Docker Hub
|
||||
type DockerHubTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
// NewRegistryClient creates a new registry client with caching
|
||||
func NewRegistryClient() *RegistryClient {
|
||||
return &RegistryClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
cache: &manifestCache{
|
||||
entries: make(map[string]*cacheEntry),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRemoteDigest fetches the digest of a remote image from the registry
|
||||
// Returns the digest string (e.g., "sha256:abc123...") or an error
|
||||
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
|
||||
registry, repository := parseImageName(imageName)
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
|
||||
if digest := c.cache.get(cacheKey); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
token, err := c.getAuthToken(ctx, registry, repository)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get auth token: %w", err)
|
||||
}
|
||||
|
||||
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
|
||||
c.cache.set(cacheKey, digest, 5*time.Minute)
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// parseImageName splits an image name into registry and repository
|
||||
// Examples:
|
||||
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
|
||||
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
|
||||
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
|
||||
func parseImageName(imageName string) (registry, repository string) {
|
||||
parts := strings.Split(imageName, "/")
|
||||
|
||||
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
registry = parts[0]
|
||||
repository = strings.Join(parts[1:], "/")
|
||||
} else if len(parts) == 1 {
|
||||
registry = "registry-1.docker.io"
|
||||
repository = "library/" + parts[0]
|
||||
} else {
|
||||
registry = "registry-1.docker.io"
|
||||
repository = imageName
|
||||
}
|
||||
|
||||
return registry, repository
|
||||
}
|
||||
|
||||
// getAuthToken obtains an authentication token for the registry
|
||||
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
|
||||
if registry == "registry-1.docker.io" {
|
||||
return c.getDockerHubToken(ctx, repository)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getDockerHubToken obtains a token from Docker Hub's authentication service
|
||||
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
|
||||
authURL := fmt.Sprintf(
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
|
||||
repository,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp DockerHubTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.Token != "" {
|
||||
return tokenResp.Token, nil
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
|
||||
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
|
||||
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
var manifest ManifestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
return "", fmt.Errorf("failed to decode manifest: %w", err)
|
||||
}
|
||||
|
||||
if manifest.Config.Digest == "" {
|
||||
return "", fmt.Errorf("manifest does not contain a config digest")
|
||||
}
|
||||
|
||||
return manifest.Config.Digest, nil
|
||||
}
|
||||
|
||||
// manifestCache methods
|
||||
|
||||
func (mc *manifestCache) get(key string) string {
|
||||
mc.mu.RLock()
|
||||
defer mc.mu.RUnlock()
|
||||
|
||||
entry, exists := mc.entries[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
delete(mc.entries, key)
|
||||
return ""
|
||||
}
|
||||
|
||||
return entry.digest
|
||||
}
|
||||
|
||||
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
mc.entries[key] = &cacheEntry{
|
||||
digest: digest,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// ScanInventory returns Docker images as InventoryItems. Implements the
|
||||
// InventoryScanner interface. This is the inventory path — distinct from
|
||||
// Scan() which returns UpdateReportItems for the update path.
|
||||
func (s *DockerScanner) ScanInventory() ([]client.InventoryItem, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var items []client.InventoryItem
|
||||
seenImages := make(map[string]bool)
|
||||
|
||||
for _, c := range containers {
|
||||
imageName := c.Image
|
||||
if seenImages[imageName] {
|
||||
continue
|
||||
}
|
||||
seenImages[imageName] = true
|
||||
|
||||
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(imageName, ":")
|
||||
baseImage := parts[0]
|
||||
currentTag := "latest"
|
||||
if len(parts) > 1 {
|
||||
currentTag = parts[1]
|
||||
}
|
||||
|
||||
localDigest := imageInspect.ID
|
||||
localShortDigest := ""
|
||||
if len(localDigest) > 7 {
|
||||
digestParts := strings.SplitN(localDigest, ":", 2)
|
||||
if len(digestParts) == 2 && len(digestParts[1]) >= 12 {
|
||||
localShortDigest = digestParts[1][:12]
|
||||
}
|
||||
}
|
||||
|
||||
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
|
||||
|
||||
remoteShortDigest := ""
|
||||
if len(remoteDigest) > 7 {
|
||||
digestParts := strings.SplitN(remoteDigest, ":", 2)
|
||||
if len(digestParts) == 2 && len(digestParts[1]) >= 12 {
|
||||
remoteShortDigest = digestParts[1][:12]
|
||||
}
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
if imageInspect.Config != nil {
|
||||
labels = imageInspect.Config.Labels
|
||||
}
|
||||
|
||||
sizeBytes := imageInspect.Size
|
||||
|
||||
createdAt := imageInspect.Created
|
||||
if createdAt == "" {
|
||||
createdAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
items = append(items, client.InventoryItem{
|
||||
Ecosystem: "docker",
|
||||
ItemName: imageName,
|
||||
ItemVersion: localShortDigest,
|
||||
Description: fmt.Sprintf("Docker image %s:%s", baseImage, currentTag),
|
||||
SizeBytes: sizeBytes,
|
||||
Vendor: baseImage,
|
||||
Metadata: map[string]interface{}{
|
||||
"image_tag": currentTag,
|
||||
"image_id": localShortDigest,
|
||||
"latest_image_id": remoteShortDigest,
|
||||
"has_update": hasUpdate,
|
||||
"repository": baseImage,
|
||||
"created_at": createdAt,
|
||||
"labels": labels,
|
||||
"container_id": c.ID[:12],
|
||||
"container_names": c.Names,
|
||||
"container_state": c.State,
|
||||
"local_full_digest": localDigest,
|
||||
"remote_digest": remoteDigest,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
410
agent/internal/orchestrator/orchestrator.go
Normal file
410
agent/internal/orchestrator/orchestrator.go
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/circuitbreaker"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
)
|
||||
|
||||
// Scanner represents a generic update scanner
|
||||
type Scanner interface {
|
||||
// IsAvailable checks if the scanner is available on this system
|
||||
IsAvailable() bool
|
||||
|
||||
// Scan performs the actual scanning and returns update items
|
||||
Scan() ([]client.UpdateReportItem, error)
|
||||
|
||||
// Name returns the scanner name for logging
|
||||
Name() string
|
||||
}
|
||||
|
||||
// InventoryScanner scans for installed software, container images, and other
|
||||
// inventory data. Distinct from Scanner which scans for available updates.
|
||||
type InventoryScanner interface {
|
||||
IsAvailable() bool
|
||||
ScanInventory() ([]client.InventoryItem, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// InventoryScannerConfig holds configuration for a single inventory scanner.
|
||||
type InventoryScannerConfig struct {
|
||||
Scanner InventoryScanner
|
||||
CircuitBreaker *circuitbreaker.CircuitBreaker
|
||||
Timeout time.Duration
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// InventoryScanResult holds the result of an inventory scanner execution.
|
||||
type InventoryScanResult struct {
|
||||
ScannerName string
|
||||
Items []client.InventoryItem
|
||||
Error error
|
||||
Duration time.Duration
|
||||
Status string // "success", "failed", "disabled", "unavailable", "skipped"
|
||||
}
|
||||
|
||||
// ScannerConfig holds configuration for a single scanner
|
||||
type ScannerConfig struct {
|
||||
Scanner Scanner
|
||||
CircuitBreaker *circuitbreaker.CircuitBreaker
|
||||
Timeout time.Duration
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// ScanResult holds the result of a scanner execution
|
||||
type ScanResult struct {
|
||||
ScannerName string
|
||||
Updates []client.UpdateReportItem
|
||||
Error error
|
||||
Duration time.Duration
|
||||
Status string // "success", "failed", "disabled", "unavailable", "skipped"
|
||||
}
|
||||
|
||||
// Orchestrator manages and coordinates multiple scanners
|
||||
type Orchestrator struct {
|
||||
scanners map[string]*ScannerConfig
|
||||
inventoryScanners map[string]*InventoryScannerConfig
|
||||
logger *event.TeeLogger
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewOrchestratorWithEvents creates a new scanner orchestrator with event buffering
|
||||
func NewOrchestratorWithEvents(logger *event.TeeLogger) *Orchestrator {
|
||||
return &Orchestrator{
|
||||
scanners: make(map[string]*ScannerConfig),
|
||||
inventoryScanners: make(map[string]*InventoryScannerConfig),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterScanner adds a scanner to the orchestrator
|
||||
func (o *Orchestrator) RegisterScanner(name string, scanner Scanner, cb *circuitbreaker.CircuitBreaker, timeout time.Duration, enabled bool) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.scanners[name] = &ScannerConfig{
|
||||
Scanner: scanner,
|
||||
CircuitBreaker: cb,
|
||||
Timeout: timeout,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterInventoryScanner adds an inventory scanner to the orchestrator.
|
||||
func (o *Orchestrator) RegisterInventoryScanner(name string, scanner InventoryScanner, cb *circuitbreaker.CircuitBreaker, timeout time.Duration, enabled bool) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.inventoryScanners[name] = &InventoryScannerConfig{
|
||||
Scanner: scanner,
|
||||
CircuitBreaker: cb,
|
||||
Timeout: timeout,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// ScanInventorySingle executes a single inventory scanner by name.
|
||||
func (o *Orchestrator) ScanInventorySingle(ctx context.Context, scannerName string) (InventoryScanResult, error) {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
cfg, exists := o.inventoryScanners[scannerName]
|
||||
if !exists {
|
||||
return InventoryScanResult{
|
||||
ScannerName: scannerName,
|
||||
Status: "failed",
|
||||
Error: fmt.Errorf("inventory scanner not found: %s", scannerName),
|
||||
}, fmt.Errorf("inventory scanner not found: %s", scannerName)
|
||||
}
|
||||
|
||||
return o.executeInventoryScan(ctx, scannerName, cfg), nil
|
||||
}
|
||||
|
||||
// executeInventoryScan runs a single inventory scanner with circuit breaker and timeout protection.
|
||||
func (o *Orchestrator) executeInventoryScan(ctx context.Context, name string, cfg *InventoryScannerConfig) InventoryScanResult {
|
||||
result := InventoryScanResult{
|
||||
ScannerName: name,
|
||||
Status: "failed",
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
result.Duration = time.Since(startTime)
|
||||
}()
|
||||
|
||||
if !cfg.Enabled {
|
||||
result.Status = "disabled"
|
||||
log.Printf("[%s] Inventory scanner disabled via configuration", name)
|
||||
return result
|
||||
}
|
||||
|
||||
if !cfg.Scanner.IsAvailable() {
|
||||
result.Status = "unavailable"
|
||||
log.Printf("[%s] Inventory scanner not available on this system", name)
|
||||
return result
|
||||
}
|
||||
|
||||
log.Printf("[%s] Starting inventory scan...", name)
|
||||
|
||||
var items []client.InventoryItem
|
||||
|
||||
err := cfg.CircuitBreaker.Call(func() error {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
|
||||
type scanResult struct {
|
||||
items []client.InventoryItem
|
||||
err error
|
||||
}
|
||||
scanChan := make(chan scanResult, 1)
|
||||
|
||||
go func() {
|
||||
i, e := cfg.Scanner.ScanInventory()
|
||||
scanChan <- scanResult{items: i, err: e}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
return fmt.Errorf("inventory scan timeout after %v", cfg.Timeout)
|
||||
case res := <-scanChan:
|
||||
if res.err != nil {
|
||||
return res.err
|
||||
}
|
||||
items = res.items
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Status = "failed"
|
||||
log.Printf("[%s] Inventory scan failed: %v", name, err)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Items = items
|
||||
result.Status = "success"
|
||||
log.Printf("[%s] Inventory scan completed: found %d items (took %v)", name, len(items), result.Duration)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ScanAll executes all registered scanners in parallel
|
||||
func (o *Orchestrator) ScanAll(ctx context.Context) ([]ScanResult, []client.UpdateReportItem) {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
resultsChan := make(chan ScanResult, len(o.scanners))
|
||||
|
||||
// Launch goroutine for each scanner
|
||||
for name, scannerConfig := range o.scanners {
|
||||
wg.Add(1)
|
||||
go func(name string, cfg *ScannerConfig) {
|
||||
defer wg.Done()
|
||||
result := o.executeScan(ctx, name, cfg)
|
||||
resultsChan <- result
|
||||
}(name, scannerConfig)
|
||||
}
|
||||
|
||||
// Wait for all scanners to complete
|
||||
wg.Wait()
|
||||
close(resultsChan)
|
||||
|
||||
// Collect results
|
||||
var results []ScanResult
|
||||
var allUpdates []client.UpdateReportItem
|
||||
|
||||
for result := range resultsChan {
|
||||
results = append(results, result)
|
||||
if result.Error == nil && len(result.Updates) > 0 {
|
||||
allUpdates = append(allUpdates, result.Updates...)
|
||||
}
|
||||
}
|
||||
|
||||
return results, allUpdates
|
||||
}
|
||||
|
||||
// ScanSingle executes a single scanner by name
|
||||
func (o *Orchestrator) ScanSingle(ctx context.Context, scannerName string) (ScanResult, error) {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
cfg, exists := o.scanners[scannerName]
|
||||
if !exists {
|
||||
return ScanResult{
|
||||
ScannerName: scannerName,
|
||||
Status: "failed",
|
||||
Error: fmt.Errorf("scanner not found: %s", scannerName),
|
||||
}, fmt.Errorf("scanner not found: %s", scannerName)
|
||||
}
|
||||
|
||||
return o.executeScan(ctx, scannerName, cfg), nil
|
||||
}
|
||||
|
||||
// executeScan runs a single scanner with circuit breaker and timeout protection
|
||||
func (o *Orchestrator) executeScan(ctx context.Context, name string, cfg *ScannerConfig) ScanResult {
|
||||
result := ScanResult{
|
||||
ScannerName: name,
|
||||
Status: "failed",
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
result.Duration = time.Since(startTime)
|
||||
}()
|
||||
|
||||
// Check if enabled
|
||||
if !cfg.Enabled {
|
||||
result.Status = "disabled"
|
||||
o.logger.Info("agent", "orchestrator", "scanner", "scanner disabled", map[string]interface{}{
|
||||
"scanner_name": name,
|
||||
"status": "disabled",
|
||||
"reason": "configuration",
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Check if available
|
||||
if !cfg.Scanner.IsAvailable() {
|
||||
result.Status = "unavailable"
|
||||
o.logger.Info("agent", "orchestrator", "scanner", "scanner unavailable", map[string]interface{}{
|
||||
"scanner_name": name,
|
||||
"status": "unavailable",
|
||||
"reason": "system_incompatible",
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Execute with circuit breaker and timeout
|
||||
o.logger.Info("agent", "orchestrator", "scanner", "starting scan", map[string]interface{}{
|
||||
"scanner_name": name,
|
||||
})
|
||||
|
||||
var updates []client.UpdateReportItem
|
||||
|
||||
err := cfg.CircuitBreaker.Call(func() error {
|
||||
// Create timeout context
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Channel for scan result
|
||||
type scanResult struct {
|
||||
updates []client.UpdateReportItem
|
||||
err error
|
||||
}
|
||||
scanChan := make(chan scanResult, 1)
|
||||
|
||||
// Run scan in goroutine
|
||||
go func() {
|
||||
u, e := cfg.Scanner.Scan()
|
||||
scanChan <- scanResult{updates: u, err: e}
|
||||
}()
|
||||
|
||||
// Wait for scan or timeout
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
return fmt.Errorf("scan timeout after %v", cfg.Timeout)
|
||||
case res := <-scanChan:
|
||||
if res.err != nil {
|
||||
return res.err
|
||||
}
|
||||
updates = res.updates
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Status = "failed"
|
||||
o.logger.Error("agent", "orchestrator", "scanner", "scan failed", map[string]interface{}{
|
||||
"scanner_name": name,
|
||||
"error_type": "scan_failed",
|
||||
"error_details": err.Error(),
|
||||
"duration_ms": result.Duration.Milliseconds(),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
result.Updates = updates
|
||||
result.Status = "success"
|
||||
o.logger.Info("agent", "orchestrator", "scanner", "scan completed", map[string]interface{}{
|
||||
"scanner_name": name,
|
||||
"updates_found": len(updates),
|
||||
"duration_ms": result.Duration.Milliseconds(),
|
||||
"status": "success",
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetScannerNames returns a list of all registered scanner names
|
||||
func (o *Orchestrator) GetScannerNames() []string {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(o.scanners))
|
||||
for name := range o.scanners {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// FormatScanSummary creates a human-readable summary of scan results
|
||||
func FormatScanSummary(results []ScanResult) (stdout string, stderr string, exitCode int) {
|
||||
var successResults []string
|
||||
var errorMessages []string
|
||||
totalUpdates := 0
|
||||
|
||||
for _, result := range results {
|
||||
switch result.Status {
|
||||
case "success":
|
||||
msg := fmt.Sprintf("%s: Found %d updates (%.2fs)",
|
||||
result.ScannerName, len(result.Updates), result.Duration.Seconds())
|
||||
successResults = append(successResults, msg)
|
||||
totalUpdates += len(result.Updates)
|
||||
|
||||
case "failed":
|
||||
msg := fmt.Sprintf("%s: %v", result.ScannerName, result.Error)
|
||||
errorMessages = append(errorMessages, msg)
|
||||
|
||||
case "disabled":
|
||||
successResults = append(successResults, fmt.Sprintf("%s: Disabled", result.ScannerName))
|
||||
|
||||
case "unavailable":
|
||||
successResults = append(successResults, fmt.Sprintf("%s: Not available", result.ScannerName))
|
||||
}
|
||||
}
|
||||
|
||||
// Build stdout
|
||||
if len(successResults) > 0 {
|
||||
stdout = "Scan Results:\n"
|
||||
for _, msg := range successResults {
|
||||
stdout += fmt.Sprintf(" - %s\n", msg)
|
||||
}
|
||||
stdout += fmt.Sprintf("\nTotal Updates Found: %d\n", totalUpdates)
|
||||
}
|
||||
|
||||
// Build stderr
|
||||
if len(errorMessages) > 0 {
|
||||
stderr = "Scan Errors:\n"
|
||||
for _, msg := range errorMessages {
|
||||
stderr += fmt.Sprintf(" - %s\n", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine exit code
|
||||
if len(errorMessages) > 0 {
|
||||
exitCode = 1
|
||||
} else {
|
||||
exitCode = 0
|
||||
}
|
||||
|
||||
return stdout, stderr, exitCode
|
||||
}
|
||||
86
agent/internal/orchestrator/storage_scanner.go
Normal file
86
agent/internal/orchestrator/storage_scanner.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package orchestrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
)
|
||||
|
||||
// StorageScanner scans disk usage metrics
|
||||
type StorageScanner struct {
|
||||
agentVersion string
|
||||
}
|
||||
|
||||
// NewStorageScanner creates a new storage scanner
|
||||
func NewStorageScanner(agentVersion string) *StorageScanner {
|
||||
return &StorageScanner{
|
||||
agentVersion: agentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable always returns true since storage scanning is always available
|
||||
func (s *StorageScanner) IsAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Scan collects disk usage and returns UpdateReportItems with full data in Metadata
|
||||
func (s *StorageScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
sysInfo, err := system.GetSystemInfo(s.agentVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get system info: %w", err)
|
||||
}
|
||||
|
||||
if len(sysInfo.DiskInfo) == 0 {
|
||||
return nil, fmt.Errorf("no disk information available")
|
||||
}
|
||||
|
||||
var items []client.UpdateReportItem
|
||||
|
||||
for _, disk := range sysInfo.DiskInfo {
|
||||
severity := "low"
|
||||
switch {
|
||||
case disk.UsedPercent >= 95:
|
||||
severity = "critical"
|
||||
case disk.UsedPercent >= 90:
|
||||
severity = "important"
|
||||
case disk.UsedPercent >= 80:
|
||||
severity = "moderate"
|
||||
}
|
||||
|
||||
item := client.UpdateReportItem{
|
||||
PackageType: "storage",
|
||||
PackageName: disk.Mountpoint,
|
||||
PackageDescription: fmt.Sprintf("Storage metrics for %s (%s)", disk.Mountpoint, disk.Filesystem),
|
||||
CurrentVersion: fmt.Sprintf("%.1f%% used", disk.UsedPercent),
|
||||
AvailableVersion: fmt.Sprintf("%.1f GB free", float64(disk.Available)/1024/1024/1024),
|
||||
Severity: severity,
|
||||
RepositorySource: disk.Device,
|
||||
SizeBytes: int64(disk.Total),
|
||||
Metadata: map[string]interface{}{
|
||||
"mountpoint": disk.Mountpoint,
|
||||
"device": disk.Device,
|
||||
"disk_type": disk.DiskType,
|
||||
"filesystem": disk.Filesystem,
|
||||
"total_bytes": int64(disk.Total),
|
||||
"used_bytes": int64(disk.Used),
|
||||
"available_bytes": int64(disk.Available),
|
||||
"used_percent": disk.UsedPercent,
|
||||
"is_root": disk.IsRoot,
|
||||
"is_largest": disk.IsLargest,
|
||||
"severity": severity,
|
||||
"agent_version": s.agentVersion,
|
||||
"collected_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *StorageScanner) Name() string {
|
||||
return "Disk Usage Reporter"
|
||||
}
|
||||
139
agent/internal/orchestrator/system_scanner.go
Normal file
139
agent/internal/orchestrator/system_scanner.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package orchestrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
)
|
||||
|
||||
// SystemScanner scans system metrics (CPU, memory, processes, uptime)
|
||||
type SystemScanner struct {
|
||||
agentVersion string
|
||||
}
|
||||
|
||||
// NewSystemScanner creates a new system scanner
|
||||
func NewSystemScanner(agentVersion string) *SystemScanner {
|
||||
return &SystemScanner{
|
||||
agentVersion: agentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable always returns true since system scanning is always available
|
||||
func (s *SystemScanner) IsAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Scan collects system information and returns UpdateReportItems with full data in Metadata
|
||||
func (s *SystemScanner) Scan() ([]client.UpdateReportItem, error) {
|
||||
sysInfo, err := system.GetSystemInfo(s.agentVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get system info: %w", err)
|
||||
}
|
||||
|
||||
var items []client.UpdateReportItem
|
||||
|
||||
// CPU
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-cpu",
|
||||
PackageDescription: "System CPU information",
|
||||
CurrentVersion: fmt.Sprintf("%d cores, %d threads", sysInfo.CPUInfo.Cores, sysInfo.CPUInfo.Threads),
|
||||
AvailableVersion: sysInfo.CPUInfo.ModelName,
|
||||
Severity: "low",
|
||||
RepositorySource: "cpu",
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": "system-cpu",
|
||||
"metric_type": "cpu",
|
||||
"cpu_model": sysInfo.CPUInfo.ModelName,
|
||||
"cpu_cores": fmt.Sprintf("%d", sysInfo.CPUInfo.Cores),
|
||||
"cpu_threads": fmt.Sprintf("%d", sysInfo.CPUInfo.Threads),
|
||||
},
|
||||
})
|
||||
|
||||
// Memory
|
||||
memSeverity := "low"
|
||||
switch {
|
||||
case sysInfo.MemoryInfo.UsedPercent >= 95:
|
||||
memSeverity = "critical"
|
||||
case sysInfo.MemoryInfo.UsedPercent >= 90:
|
||||
memSeverity = "important"
|
||||
case sysInfo.MemoryInfo.UsedPercent >= 80:
|
||||
memSeverity = "moderate"
|
||||
}
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-memory",
|
||||
PackageDescription: "System memory information",
|
||||
CurrentVersion: fmt.Sprintf("%.1f%% used", sysInfo.MemoryInfo.UsedPercent),
|
||||
AvailableVersion: fmt.Sprintf("%d GB total", sysInfo.MemoryInfo.Total/(1024*1024*1024)),
|
||||
Severity: memSeverity,
|
||||
RepositorySource: "memory",
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": "system-memory",
|
||||
"metric_type": "memory",
|
||||
"memory_total": fmt.Sprintf("%d", sysInfo.MemoryInfo.Total),
|
||||
"memory_used": fmt.Sprintf("%d", sysInfo.MemoryInfo.Used),
|
||||
"memory_available": fmt.Sprintf("%d", sysInfo.MemoryInfo.Available),
|
||||
"memory_used_percent": fmt.Sprintf("%.1f", sysInfo.MemoryInfo.UsedPercent),
|
||||
},
|
||||
})
|
||||
|
||||
// Processes
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-processes",
|
||||
PackageDescription: "Running processes",
|
||||
CurrentVersion: fmt.Sprintf("%d processes", sysInfo.RunningProcesses),
|
||||
AvailableVersion: "n/a",
|
||||
Severity: "low",
|
||||
RepositorySource: "processes",
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": "system-processes",
|
||||
"metric_type": "processes",
|
||||
"process_count": fmt.Sprintf("%d", sysInfo.RunningProcesses),
|
||||
},
|
||||
})
|
||||
|
||||
// Uptime
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-uptime",
|
||||
PackageDescription: "System uptime",
|
||||
CurrentVersion: sysInfo.Uptime,
|
||||
AvailableVersion: "n/a",
|
||||
Severity: "low",
|
||||
RepositorySource: "uptime",
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": "system-uptime",
|
||||
"metric_type": "uptime",
|
||||
"uptime": sysInfo.Uptime,
|
||||
},
|
||||
})
|
||||
|
||||
// Reboot required
|
||||
if sysInfo.RebootRequired {
|
||||
items = append(items, client.UpdateReportItem{
|
||||
PackageType: "system",
|
||||
PackageName: "system-reboot",
|
||||
PackageDescription: "System reboot status",
|
||||
CurrentVersion: "required",
|
||||
AvailableVersion: "n/a",
|
||||
Severity: "important",
|
||||
RepositorySource: "reboot",
|
||||
Metadata: map[string]interface{}{
|
||||
"metric_name": "system-reboot",
|
||||
"metric_type": "reboot",
|
||||
"reboot_required": "true",
|
||||
"reboot_reason": sysInfo.RebootReason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Name returns the scanner name
|
||||
func (s *SystemScanner) Name() string {
|
||||
return "System Metrics Reporter"
|
||||
}
|
||||
Loading…
Reference in a new issue