// Package config provides configuration loading and validation for ja4sentinel package config import ( "encoding/json" "fmt" "os" "strconv" "strings" "gopkg.in/yaml.v3" "ja4sentinel/api" ) // LoaderImpl implements the api.Loader interface for configuration loading type LoaderImpl struct { configPath string } // NewLoader creates a new configuration loader func NewLoader(configPath string) *LoaderImpl { return &LoaderImpl{ configPath: configPath, } } // Load reads and merges configuration from file, environment variables, and CLI func (l *LoaderImpl) Load() (api.AppConfig, error) { config := api.DefaultConfig() // Load from YAML file if path is provided if l.configPath != "" { fileConfig, err := l.loadFromFile(l.configPath) if err != nil { return config, fmt.Errorf("failed to load config file: %w", err) } config = mergeConfigs(config, fileConfig) } // Override with environment variables config = l.loadFromEnv(config) // Validate the final configuration if err := l.validate(config); err != nil { return config, fmt.Errorf("invalid configuration: %w", err) } return config, nil } // loadFromFile reads configuration from a YAML file func (l *LoaderImpl) loadFromFile(path string) (api.AppConfig, error) { config := api.AppConfig{} data, err := os.ReadFile(path) if err != nil { return config, fmt.Errorf("failed to read config file: %w", err) } err = yaml.Unmarshal(data, &config) if err != nil { return config, fmt.Errorf("failed to parse config file: %w", err) } return config, nil } // loadFromEnv overrides configuration with environment variables func (l *LoaderImpl) loadFromEnv(config api.AppConfig) api.AppConfig { // JA4SENTINEL_INTERFACE if val := os.Getenv("JA4SENTINEL_INTERFACE"); val != "" { config.Core.Interface = val } // JA4SENTINEL_PORTS (comma-separated list) if val := os.Getenv("JA4SENTINEL_PORTS"); val != "" { ports := parsePorts(val) if len(ports) > 0 { config.Core.ListenPorts = ports } } // JA4SENTINEL_BPF_FILTER if val := os.Getenv("JA4SENTINEL_BPF_FILTER"); val != "" { config.Core.BPFFilter = val } // JA4SENTINEL_FLOW_TIMEOUT (in seconds) if val := os.Getenv("JA4SENTINEL_FLOW_TIMEOUT"); val != "" { if timeout, err := strconv.Atoi(val); err == nil && timeout > 0 { config.Core.FlowTimeoutSec = timeout } } return config } // parsePorts parses a comma-separated list of ports func parsePorts(s string) []uint16 { if s == "" { return nil } parts := strings.Split(s, ",") ports := make([]uint16, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } port, err := strconv.ParseUint(part, 10, 16) if err == nil { ports = append(ports, uint16(port)) } } return ports } // mergeConfigs merges two configs, with override taking precedence func mergeConfigs(base, override api.AppConfig) api.AppConfig { result := base if override.Core.Interface != "" { result.Core.Interface = override.Core.Interface } if len(override.Core.ListenPorts) > 0 { result.Core.ListenPorts = override.Core.ListenPorts } if override.Core.BPFFilter != "" { result.Core.BPFFilter = override.Core.BPFFilter } if override.Core.FlowTimeoutSec > 0 { result.Core.FlowTimeoutSec = override.Core.FlowTimeoutSec } if len(override.Outputs) > 0 { result.Outputs = override.Outputs } return result } // validate checks if the configuration is valid func (l *LoaderImpl) validate(config api.AppConfig) error { if config.Core.Interface == "" { return fmt.Errorf("interface cannot be empty") } if len(config.Core.ListenPorts) == 0 { return fmt.Errorf("at least one listen port is required") } // Validate outputs for i, output := range config.Outputs { if output.Type == "" { return fmt.Errorf("output[%d]: type cannot be empty", i) } } return nil } // ToJSON converts config to JSON string for debugging func ToJSON(config api.AppConfig) string { data, err := json.MarshalIndent(config, "", " ") if err != nil { return fmt.Sprintf("error marshaling config: %v", err) } return string(data) }