Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
Co-authored-by: aider (openrouter/openai/gpt-5.3-codex) <aider@aider.chat>
238 lines
5.6 KiB
Go
238 lines
5.6 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
|
|
}
|
|
}
|
|
|
|
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 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")
|
|
}
|
|
|
|
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)
|
|
}
|