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 // Check if subsystem is zero value (truly missing), not just has zero fields if cfg.Subsystems.System == (SubsystemConfig{}) { fmt.Printf("[CONFIG] Adding missing 'system' subsystem configuration\n") cfg.Subsystems.System = GetDefaultSubsystemsConfig().System } if cfg.Subsystems.Updates == (SubsystemConfig{}) { 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 } }