Files
ja4sentinel/internal/config/loader.go
Jacquin Antoine fd162982d9
Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
v1.0.8: Add configurable log level and immediate service stop
- Add log_level config option (debug, info, warn, error)
- Add JA4SENTINEL_LOG_LEVEL environment variable support
- Set TimeoutStopSec=2 for immediate stop on restart/stop
- Consolidate config files into single example (config.yml.example)
- Update RPM changelog

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-01 02:51:11 +01:00

260 lines
6.1 KiB
Go

// Package config provides configuration loading and validation for ja4sentinel
package config
import (
"encoding/json"
"errors"
"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()
path := l.configPath
explicit := path != ""
if !explicit {
path = "config.yml"
}
fileConfig, err := l.loadFromFile(path)
if err == nil {
config = mergeConfigs(config, fileConfig)
} else if !(!explicit && errors.Is(err, os.ErrNotExist)) {
return config, fmt.Errorf("failed to load config file: %w", err)
}
// 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
}
}
// JA4SENTINEL_PACKET_BUFFER_SIZE
if val := os.Getenv("JA4SENTINEL_PACKET_BUFFER_SIZE"); val != "" {
if size, err := strconv.Atoi(val); err == nil && size > 0 {
config.Core.PacketBufferSize = size
}
}
// JA4SENTINEL_LOG_LEVEL
if val := os.Getenv("JA4SENTINEL_LOG_LEVEL"); val != "" {
config.Core.LogLevel = val
}
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))
seen := make(map[uint16]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
port, err := strconv.ParseUint(part, 10, 16)
if err != nil {
continue
}
p := uint16(port)
if p == 0 {
continue
}
if _, exists := seen[p]; exists {
continue
}
seen[p] = struct{}{}
ports = append(ports, p)
}
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 override.Core.PacketBufferSize > 0 {
result.Core.PacketBufferSize = override.Core.PacketBufferSize
}
if override.Core.LogLevel != "" {
result.Core.LogLevel = override.Core.LogLevel
}
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 strings.TrimSpace(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")
}
for _, p := range config.Core.ListenPorts {
if p == 0 {
return fmt.Errorf("listen port 0 is invalid")
}
}
if config.Core.FlowTimeoutSec <= 0 || config.Core.FlowTimeoutSec > 300 {
return fmt.Errorf("flow_timeout_sec must be between 1 and 300")
}
if config.Core.PacketBufferSize <= 0 || config.Core.PacketBufferSize > 1_000_000 {
return fmt.Errorf("packet_buffer_size must be between 1 and 1000000")
}
// Validate log level
validLogLevels := map[string]struct{}{
"debug": {},
"info": {},
"warn": {},
"error": {},
}
if config.Core.LogLevel != "" {
if _, ok := validLogLevels[config.Core.LogLevel]; !ok {
return fmt.Errorf("log_level must be one of: debug, info, warn, error")
}
}
allowedTypes := map[string]struct{}{
"stdout": {},
"file": {},
"unix_socket": {},
}
// Validate outputs
for i, output := range config.Outputs {
outputType := strings.TrimSpace(output.Type)
if outputType == "" {
return fmt.Errorf("output[%d]: type cannot be empty", i)
}
if _, ok := allowedTypes[outputType]; !ok {
return fmt.Errorf("output[%d]: unknown type %q", i, outputType)
}
switch outputType {
case "file":
if strings.TrimSpace(output.Params["path"]) == "" {
return fmt.Errorf("output[%d]: file output requires non-empty path", i)
}
case "unix_socket":
if strings.TrimSpace(output.Params["socket_path"]) == "" {
return fmt.Errorf("output[%d]: unix_socket output requires non-empty socket_path", 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)
}