Files
Redflag/aggregator-agent/internal/config/config.go

669 lines
21 KiB
Go

package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/version"
"github.com/google/uuid"
)
// MigrationState tracks migration completion status (used by migration package)
type MigrationState struct {
LastCompleted map[string]time.Time `json:"last_completed"`
AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version"`
Timestamp time.Time `json:"timestamp"`
Success bool `json:"success"`
RollbackPath string `json:"rollback_path,omitempty"`
CompletedMigrations []string `json:"completed_migrations"`
}
// ProxyConfig holds proxy configuration
type ProxyConfig struct {
Enabled bool `json:"enabled"`
HTTP string `json:"http,omitempty"` // HTTP proxy URL
HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
Username string `json:"username,omitempty"` // Proxy username (optional)
Password string `json:"password,omitempty"` // Proxy password (optional)
}
// TLSConfig holds TLS/security configuration
type TLSConfig struct {
InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification
CertFile string `json:"cert_file,omitempty"` // Client certificate file
KeyFile string `json:"key_file,omitempty"` // Client key file
CAFile string `json:"ca_file,omitempty"` // CA certificate file
}
// NetworkConfig holds network-related configuration
type NetworkConfig struct {
Timeout time.Duration `json:"timeout"` // Request timeout
RetryCount int `json:"retry_count"` // Number of retries
RetryDelay time.Duration `json:"retry_delay"` // Delay between retries
MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections
}
// LoggingConfig holds logging configuration
type LoggingConfig struct {
Level string `json:"level"` // Log level (debug, info, warn, error)
File string `json:"file,omitempty"` // Log file path (optional)
MaxSize int `json:"max_size"` // Max log file size in MB
MaxBackups int `json:"max_backups"` // Max number of log file backups
MaxAge int `json:"max_age"` // Max age of log files in days
}
// SecurityLogConfig holds configuration for security logging
type SecurityLogConfig struct {
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_SECURITY_LOG_ENABLED" default:"true"`
Level string `json:"level" env:"REDFLAG_AGENT_SECURITY_LOG_LEVEL" default:"warning"` // none, error, warn, info, debug
LogSuccesses bool `json:"log_successes" env:"REDFLAG_AGENT_SECURITY_LOG_SUCCESSES" default:"false"`
FilePath string `json:"file_path" env:"REDFLAG_AGENT_SECURITY_LOG_PATH"` // Relative to agent data directory
MaxSizeMB int `json:"max_size_mb" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_SIZE" default:"50"`
MaxFiles int `json:"max_files" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_FILES" default:"5"`
BatchSize int `json:"batch_size" env:"REDFLAG_AGENT_SECURITY_LOG_BATCH_SIZE" default:"10"`
SendToServer bool `json:"send_to_server" env:"REDFLAG_AGENT_SECURITY_LOG_SEND" default:"true"`
}
// CommandSigningConfig holds configuration for command signature verification
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"` // strict, warning, disabled
}
// Config holds agent configuration
type Config struct {
// Version Information
Version string `json:"version,omitempty"` // Config schema version
AgentVersion string `json:"agent_version,omitempty"` // Agent binary version
// Server Configuration
ServerURL string `json:"server_url"`
RegistrationToken string `json:"registration_token,omitempty"` // One-time registration token
// Agent Authentication
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"` // Short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
// Agent Behavior
CheckInInterval int `json:"check_in_interval"`
// Rapid polling mode for faster response during operations
RapidPollingEnabled bool `json:"rapid_polling_enabled"`
RapidPollingUntil time.Time `json:"rapid_polling_until"`
// Network Configuration
Network NetworkConfig `json:"network,omitempty"`
// Proxy Configuration
Proxy ProxyConfig `json:"proxy,omitempty"`
// Security Configuration
TLS TLSConfig `json:"tls,omitempty"`
// Logging Configuration
Logging LoggingConfig `json:"logging,omitempty"`
// Security Logging Configuration
SecurityLogging SecurityLogConfig `json:"security_logging,omitempty"`
// Command Signing Configuration
CommandSigning CommandSigningConfig `json:"command_signing,omitempty"`
// Agent Metadata
Tags []string `json:"tags,omitempty"` // User-defined tags
Metadata map[string]string `json:"metadata,omitempty"` // Custom metadata
DisplayName string `json:"display_name,omitempty"` // Human-readable name
Organization string `json:"organization,omitempty"` // Organization/group
// Subsystem Configuration
Subsystems SubsystemsConfig `json:"subsystems,omitempty"` // Scanner subsystem configs
// Migration State
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking
}
// Load reads configuration from multiple sources with priority order:
// 1. CLI flags
// 2. Environment variables
// 3. Configuration file
// 4. Default values
func Load(configPath string, cliFlags *CLIFlags) (*Config, error) {
// Load existing config from file first
config, err := loadFromFile(configPath)
if err != nil {
// Only use defaults if file doesn't exist or can't be read
config = getDefaultConfig()
}
// Override with environment variables
mergeConfig(config, loadFromEnv())
// Override with CLI flags (highest priority)
if cliFlags != nil {
mergeConfig(config, loadFromFlags(cliFlags))
}
// Validate configuration
if err := validateConfig(config); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// CLIFlags holds command line flag values
type CLIFlags struct {
ServerURL string
RegistrationToken string
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
ConfigFile string
Tags []string
Organization string
DisplayName string
InsecureTLS bool
}
// getConfigVersionForAgent extracts the config version from the agent version
// Agent version format: v0.1.23.6 where the fourth octet (.6) maps to config version
func getConfigVersionForAgent(agentVersion string) string {
// Strip 'v' prefix if present
cleanVersion := strings.TrimPrefix(agentVersion, "v")
// Split version parts
parts := strings.Split(cleanVersion, ".")
if len(parts) == 4 {
// Return the fourth octet as the config version
// v0.1.23.6 → "6"
return parts[3]
}
// TODO: Integrate with global error logging system when available
// For now, default to "6" to match current agent version
return "6"
}
// getDefaultConfig returns default configuration values
func getDefaultConfig() *Config {
// Use version package for single source of truth
configVersion := version.ConfigVersion
if configVersion == "dev" {
// Fallback to extracting from agent version if not injected
configVersion = version.ExtractConfigVersionFromAgent(version.Version)
}
return &Config{
Version: configVersion, // Config schema version from version package
AgentVersion: version.Version, // Agent version from version package
ServerURL: "http://localhost:8080",
CheckInInterval: 300, // 5 minutes
// Server Authentication
RegistrationToken: "", // One-time registration token (embedded by install script)
AgentID: uuid.Nil, // Will be set during registration
Token: "", // Will be set during registration
RefreshToken: "", // Will be set during registration
// Agent Behavior
RapidPollingEnabled: false,
RapidPollingUntil: time.Time{},
// Network Security
Proxy: ProxyConfig{},
TLS: TLSConfig{},
Network: NetworkConfig{
Timeout: 30 * time.Second,
RetryCount: 3,
RetryDelay: 5 * time.Second,
MaxIdleConn: 10,
},
Logging: LoggingConfig{
Level: "info",
MaxSize: 100, // 100MB
MaxBackups: 3,
MaxAge: 28, // 28 days
},
SecurityLogging: SecurityLogConfig{
Enabled: true,
Level: "warning",
LogSuccesses: false,
FilePath: "security.log",
MaxSizeMB: 50,
MaxFiles: 5,
BatchSize: 10,
SendToServer: true,
},
CommandSigning: CommandSigningConfig{
Enabled: true,
EnforcementMode: "strict",
},
Subsystems: GetDefaultSubsystemsConfig(),
Tags: []string{},
Metadata: make(map[string]string),
}
}
// loadFromFile reads configuration from file with backward compatibility migration
func loadFromFile(configPath string) (*Config, error) {
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
// Read config file
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("config file does not exist") // Return error so caller uses defaults
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
// Parse the existing config into a generic map to preserve all fields
var rawConfig map[string]interface{}
if err := json.Unmarshal(data, &rawConfig); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Create a new config with ALL defaults to fill missing fields
config := getDefaultConfig()
// Carefully merge the loaded config into our defaults
// This preserves existing values while filling missing ones with defaults
configJSON, err := json.Marshal(rawConfig)
if err != nil {
return nil, fmt.Errorf("failed to re-marshal config: %w", err)
}
// Create a temporary config to hold loaded values
tempConfig := &Config{}
if err := json.Unmarshal(configJSON, &tempConfig); err != nil {
return nil, fmt.Errorf("failed to unmarshal temp config: %w", err)
}
// Merge loaded config into defaults (only non-zero values)
mergeConfigPreservingDefaults(config, tempConfig)
// Handle specific migrations for known breaking changes
migrateConfig(config)
return config, nil
}
// migrateConfig handles specific known migrations between config versions
func migrateConfig(cfg *Config) {
// Save the registration token before migration
savedRegistrationToken := cfg.RegistrationToken
// Update config schema version to latest
targetVersion := version.ConfigVersion
if targetVersion == "dev" {
// Fallback to extracting from agent version
targetVersion = version.ExtractConfigVersionFromAgent(version.Version)
}
if cfg.Version != targetVersion {
fmt.Printf("[CONFIG] Migrating config schema from version %s to %s\n", cfg.Version, targetVersion)
cfg.Version = targetVersion
}
// Migration 1: Ensure minimum check-in interval (30 seconds)
if cfg.CheckInInterval < 30 {
fmt.Printf("[CONFIG] Migrating check_in_interval from %d to minimum 30 seconds\n", cfg.CheckInInterval)
cfg.CheckInInterval = 300 // Default to 5 minutes for better performance
}
// Migration 2: Add missing subsystem fields with defaults
if cfg.Subsystems.System.Timeout == 0 && cfg.Subsystems.System.CircuitBreaker.FailureThreshold == 0 {
fmt.Printf("[CONFIG] Adding missing 'system' subsystem configuration\n")
cfg.Subsystems.System = GetDefaultSubsystemsConfig().System
}
if cfg.Subsystems.Updates.Timeout == 0 && cfg.Subsystems.Updates.CircuitBreaker.FailureThreshold == 0 {
fmt.Printf("[CONFIG] Adding missing 'updates' subsystem configuration\n")
cfg.Subsystems.Updates = GetDefaultSubsystemsConfig().Updates
}
// CRITICAL: Restore the registration token after migration
// This ensures the token is never overwritten by migration logic
if savedRegistrationToken != "" {
cfg.RegistrationToken = savedRegistrationToken
}
}
// loadFromEnv loads configuration from environment variables
func loadFromEnv() *Config {
config := &Config{}
if serverURL := os.Getenv("REDFLAG_SERVER_URL"); serverURL != "" {
config.ServerURL = serverURL
}
if token := os.Getenv("REDFLAG_REGISTRATION_TOKEN"); token != "" {
config.RegistrationToken = token
}
if proxyHTTP := os.Getenv("REDFLAG_HTTP_PROXY"); proxyHTTP != "" {
config.Proxy.Enabled = true
config.Proxy.HTTP = proxyHTTP
}
if proxyHTTPS := os.Getenv("REDFLAG_HTTPS_PROXY"); proxyHTTPS != "" {
config.Proxy.Enabled = true
config.Proxy.HTTPS = proxyHTTPS
}
if noProxy := os.Getenv("REDFLAG_NO_PROXY"); noProxy != "" {
config.Proxy.NoProxy = noProxy
}
if logLevel := os.Getenv("REDFLAG_LOG_LEVEL"); logLevel != "" {
if config.Logging == (LoggingConfig{}) {
config.Logging = LoggingConfig{}
}
config.Logging.Level = logLevel
}
if org := os.Getenv("REDFLAG_ORGANIZATION"); org != "" {
config.Organization = org
}
if displayName := os.Getenv("REDFLAG_DISPLAY_NAME"); displayName != "" {
config.DisplayName = displayName
}
// Security logging environment variables
if secEnabled := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_ENABLED"); secEnabled != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.Enabled = secEnabled == "true"
}
if secLevel := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_LEVEL"); secLevel != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.Level = secLevel
}
if secLogSucc := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_SUCCESSES"); secLogSucc != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.LogSuccesses = secLogSucc == "true"
}
if secPath := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_PATH"); secPath != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.FilePath = secPath
}
return config
}
// loadFromFlags loads configuration from CLI flags
func loadFromFlags(flags *CLIFlags) *Config {
config := &Config{}
if flags.ServerURL != "" {
config.ServerURL = flags.ServerURL
}
if flags.RegistrationToken != "" {
config.RegistrationToken = flags.RegistrationToken
}
if flags.ProxyHTTP != "" || flags.ProxyHTTPS != "" {
config.Proxy = ProxyConfig{
Enabled: true,
HTTP: flags.ProxyHTTP,
HTTPS: flags.ProxyHTTPS,
NoProxy: flags.ProxyNoProxy,
}
}
if flags.LogLevel != "" {
config.Logging = LoggingConfig{
Level: flags.LogLevel,
}
}
if len(flags.Tags) > 0 {
config.Tags = flags.Tags
}
if flags.Organization != "" {
config.Organization = flags.Organization
}
if flags.DisplayName != "" {
config.DisplayName = flags.DisplayName
}
if flags.InsecureTLS {
config.TLS = TLSConfig{
InsecureSkipVerify: true,
}
}
return config
}
// mergeConfig merges source config into target config (non-zero values only)
func mergeConfig(target, source *Config) {
if source.ServerURL != "" {
target.ServerURL = source.ServerURL
}
if source.RegistrationToken != "" {
target.RegistrationToken = source.RegistrationToken
}
if source.CheckInInterval != 0 {
target.CheckInInterval = source.CheckInInterval
}
if source.AgentID != uuid.Nil {
target.AgentID = source.AgentID
}
if source.Token != "" {
target.Token = source.Token
}
if source.RefreshToken != "" {
target.RefreshToken = source.RefreshToken
}
// Merge nested configs
if source.Network != (NetworkConfig{}) {
target.Network = source.Network
}
if source.Proxy != (ProxyConfig{}) {
target.Proxy = source.Proxy
}
if source.TLS != (TLSConfig{}) {
target.TLS = source.TLS
}
if source.Logging != (LoggingConfig{}) {
target.Logging = source.Logging
}
if source.SecurityLogging != (SecurityLogConfig{}) {
target.SecurityLogging = source.SecurityLogging
}
if source.CommandSigning != (CommandSigningConfig{}) {
target.CommandSigning = source.CommandSigning
}
// Merge metadata
if source.Tags != nil {
target.Tags = source.Tags
}
if source.Metadata != nil {
if target.Metadata == nil {
target.Metadata = make(map[string]string)
}
for k, v := range source.Metadata {
target.Metadata[k] = v
}
}
if source.DisplayName != "" {
target.DisplayName = source.DisplayName
}
if source.Organization != "" {
target.Organization = source.Organization
}
// Merge rapid polling settings
target.RapidPollingEnabled = source.RapidPollingEnabled
if !source.RapidPollingUntil.IsZero() {
target.RapidPollingUntil = source.RapidPollingUntil
}
// Merge subsystems config
if source.Subsystems != (SubsystemsConfig{}) {
target.Subsystems = source.Subsystems
}
}
// validateConfig validates configuration values
func validateConfig(config *Config) error {
if config.ServerURL == "" {
return fmt.Errorf("server_url is required")
}
if config.CheckInInterval < 30 {
return fmt.Errorf("check_in_interval must be at least 30 seconds")
}
if config.CheckInInterval > 3600 {
return fmt.Errorf("check_in_interval cannot exceed 3600 seconds (1 hour)")
}
if config.Network.Timeout <= 0 {
return fmt.Errorf("network timeout must be positive")
}
if config.Network.RetryCount < 0 || config.Network.RetryCount > 10 {
return fmt.Errorf("retry_count must be between 0 and 10")
}
// Validate log level
validLogLevels := map[string]bool{
"debug": true, "info": true, "warn": true, "error": true,
}
if config.Logging.Level != "" && !validLogLevels[config.Logging.Level] {
return fmt.Errorf("invalid log level: %s", config.Logging.Level)
}
return nil
}
// Save writes configuration to file
func (c *Config) Save(configPath string) error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Create parent directory if it doesn't exist
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
// IsRegistered checks if the agent is registered
func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
}
// NeedsRegistration checks if the agent needs to register with a token
func (c *Config) NeedsRegistration() bool {
return c.RegistrationToken != "" && c.AgentID == uuid.Nil
}
// HasRegistrationToken checks if the agent has a registration token
func (c *Config) HasRegistrationToken() bool {
return c.RegistrationToken != ""
}
// mergeConfigPreservingDefaults merges source config into target config
// but only overwrites fields that are explicitly set (non-zero)
// This is different from mergeConfig which blindly copies non-zero values
func mergeConfigPreservingDefaults(target, source *Config) {
// Server Configuration
if source.ServerURL != "" && source.ServerURL != getDefaultConfig().ServerURL {
target.ServerURL = source.ServerURL
}
// IMPORTANT: Never overwrite registration token if target already has one
if source.RegistrationToken != "" && target.RegistrationToken == "" {
target.RegistrationToken = source.RegistrationToken
}
// Agent Configuration
if source.CheckInInterval != 0 {
target.CheckInInterval = source.CheckInInterval
}
if source.AgentID != uuid.Nil {
target.AgentID = source.AgentID
}
if source.Token != "" {
target.Token = source.Token
}
if source.RefreshToken != "" {
target.RefreshToken = source.RefreshToken
}
// Merge nested configs only if they're not default values
if source.Network != (NetworkConfig{}) {
target.Network = source.Network
}
if source.Proxy != (ProxyConfig{}) {
target.Proxy = source.Proxy
}
if source.TLS != (TLSConfig{}) {
target.TLS = source.TLS
}
if source.Logging != (LoggingConfig{}) && source.Logging.Level != "" {
target.Logging = source.Logging
}
if source.SecurityLogging != (SecurityLogConfig{}) {
target.SecurityLogging = source.SecurityLogging
}
if source.CommandSigning != (CommandSigningConfig{}) {
target.CommandSigning = source.CommandSigning
}
// Merge metadata
if source.Tags != nil && len(source.Tags) > 0 {
target.Tags = source.Tags
}
if source.Metadata != nil {
if target.Metadata == nil {
target.Metadata = make(map[string]string)
}
for k, v := range source.Metadata {
target.Metadata[k] = v
}
}
if source.DisplayName != "" {
target.DisplayName = source.DisplayName
}
if source.Organization != "" {
target.Organization = source.Organization
}
// Merge rapid polling settings
target.RapidPollingEnabled = source.RapidPollingEnabled
if !source.RapidPollingUntil.IsZero() {
target.RapidPollingUntil = source.RapidPollingUntil
}
// Merge subsystems config
if source.Subsystems != (SubsystemsConfig{}) {
target.Subsystems = source.Subsystems
}
// Version info
if source.Version != "" {
target.Version = source.Version
}
if source.AgentVersion != "" {
target.AgentVersion = source.AgentVersion
}
}