Files
Strix/internal/config/config.go
T
eduard256 8cf05a1576 Fix credentials leaking in debug logs (#4)
Add a secret-masking slog.Handler that automatically replaces registered
passwords with "***" in all log output. Secrets are registered per-scan
when a discovery request arrives and unregistered when it completes.

This approach masks credentials everywhere they appear in logs — URL
userinfo, query parameters, path segments, and Go HTTP error messages —
without modifying any business logic in scanner, builder, tester, or
ONVIF components. API responses are unaffected and still return full
URLs with credentials for frontend use.
2026-03-20 11:03:01 +00:00

268 lines
7.0 KiB
Go

package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/eduard256/Strix/internal/utils/logger"
"gopkg.in/yaml.v3"
)
// Config holds application configuration
type Config struct {
Version string // Application version, set by caller after Load()
Server ServerConfig
Database DatabaseConfig
Scanner ScannerConfig
Logger LoggerConfig
}
// ServerConfig contains HTTP server settings
type ServerConfig struct {
Listen string // Address to listen on (e.g., ":4567" or "0.0.0.0:4567")
ReadTimeout time.Duration
WriteTimeout time.Duration
}
// DatabaseConfig contains database settings
type DatabaseConfig struct {
DataPath string
BrandsPath string
PatternsPath string
ParametersPath string
OUIPath string
CacheEnabled bool
CacheTTL time.Duration
}
// ScannerConfig contains stream scanner settings
type ScannerConfig struct {
DefaultTimeout time.Duration
MaxStreams int
ModelSearchLimit int
WorkerPoolSize int
FFProbeTimeout time.Duration
RetryAttempts int
RetryDelay time.Duration
// Validation settings
StrictValidation bool // Enable strict validation mode
MinImageSize int // Minimum bytes for valid image (JPEG/PNG)
MinVideoStreams int // Minimum video streams required
}
// LoggerConfig contains logging settings
type LoggerConfig struct {
Level string
Format string // "text" or "json"
}
// yamlConfig represents the structure of strix.yaml
type yamlConfig struct {
API struct {
Listen string `yaml:"listen"`
} `yaml:"api"`
}
// Load returns configuration with defaults
func Load() *Config {
dataPath := getEnv("STRIX_DATA_PATH", "./data")
cfg := &Config{
Server: ServerConfig{
Listen: ":4567", // Default listen address
ReadTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute, // Increased for SSE long-polling
},
Database: DatabaseConfig{
DataPath: dataPath,
BrandsPath: filepath.Join(dataPath, "brands"),
PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"),
ParametersPath: filepath.Join(dataPath, "query_parameters.json"),
OUIPath: filepath.Join(dataPath, "camera_oui.json"),
CacheEnabled: true,
CacheTTL: 5 * time.Minute,
},
Scanner: ScannerConfig{
DefaultTimeout: 4 * time.Minute,
MaxStreams: 10,
ModelSearchLimit: 6,
WorkerPoolSize: 20,
FFProbeTimeout: 30 * time.Second,
RetryAttempts: 2,
RetryDelay: 500 * time.Millisecond,
// Strict validation enabled by default
StrictValidation: true,
MinImageSize: 5120, // 5KB minimum for valid images
MinVideoStreams: 1, // At least 1 video stream required
},
Logger: LoggerConfig{
Level: getEnv("STRIX_LOG_LEVEL", "info"),
Format: getEnv("STRIX_LOG_FORMAT", "json"),
},
}
// Load from Home Assistant options.json if running as HA add-on
// Priority: defaults < HA options < strix.yaml < ENV
configSource := "default"
if err := loadHAOptions(cfg); err == nil {
configSource = "/data/options.json (Home Assistant)"
}
// Load from strix.yaml if exists (overrides HA options)
if err := loadYAML(cfg); err == nil {
configSource = "strix.yaml"
}
// Environment variable overrides everything
if envListen := os.Getenv("STRIX_API_LISTEN"); envListen != "" {
cfg.Server.Listen = envListen
configSource = "environment variable STRIX_API_LISTEN"
}
// Validate listen address
if err := validateListen(cfg.Server.Listen); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: Invalid listen address '%s': %v\n", cfg.Server.Listen, err)
fmt.Fprintf(os.Stderr, "Using default: :4567\n")
cfg.Server.Listen = ":4567"
configSource = "default (validation failed)"
}
// Log configuration source
fmt.Printf("INFO: API listen address '%s' loaded from %s\n", cfg.Server.Listen, configSource)
return cfg
}
// loadYAML attempts to load configuration from strix.yaml
func loadYAML(cfg *Config) error {
data, err := os.ReadFile("./strix.yaml")
if err != nil {
return err
}
var yamlCfg yamlConfig
if err := yaml.Unmarshal(data, &yamlCfg); err != nil {
return fmt.Errorf("failed to parse strix.yaml: %w", err)
}
// Apply yaml configuration
if yamlCfg.API.Listen != "" {
cfg.Server.Listen = yamlCfg.API.Listen
}
return nil
}
// haOptions represents the structure of Home Assistant /data/options.json.
// When Strix runs as a Home Assistant add-on, HA creates this file from the
// add-on configuration UI. Fields are optional -- zero values are ignored.
type haOptions struct {
LogLevel string `json:"log_level"`
Port int `json:"port"`
}
// loadHAOptions loads configuration from Home Assistant's /data/options.json.
// This file only exists when running inside the HA add-on environment.
// Returns an error if the file doesn't exist or can't be parsed (callers
// should treat errors as "not running in HA" and silently continue).
func loadHAOptions(cfg *Config) error {
data, err := os.ReadFile("/data/options.json")
if err != nil {
return err
}
var opts haOptions
if err := json.Unmarshal(data, &opts); err != nil {
return fmt.Errorf("failed to parse /data/options.json: %w", err)
}
if opts.LogLevel != "" {
cfg.Logger.Level = opts.LogLevel
}
if opts.Port > 0 {
cfg.Server.Listen = fmt.Sprintf(":%d", opts.Port)
}
// Home Assistant add-on always uses JSON logging for the HA log viewer
cfg.Logger.Format = "json"
return nil
}
// validateListen validates the listen address format and port range
func validateListen(listen string) error {
if listen == "" {
return fmt.Errorf("listen address cannot be empty")
}
// Parse the listen address
parts := strings.Split(listen, ":")
if len(parts) < 2 {
return fmt.Errorf("invalid format, expected ':port' or 'host:port', got '%s'", listen)
}
// Get port (last part)
portStr := parts[len(parts)-1]
if portStr == "" {
return fmt.Errorf("port cannot be empty")
}
// Validate port number
port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("invalid port number '%s': %w", portStr, err)
}
if port < 1 || port > 65535 {
return fmt.Errorf("port %d out of valid range (1-65535)", port)
}
return nil
}
// SetupLogger configures the global logger. It returns the logger and a
// SecretStore that can be used to register credentials for automatic masking
// in all log output.
func (c *Config) SetupLogger() (*slog.Logger, *logger.SecretStore) {
var level slog.Level
switch c.Logger.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
opts := &slog.HandlerOptions{
Level: level,
}
var handler slog.Handler
if c.Logger.Format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
secrets := logger.NewSecretStore()
maskedHandler := logger.NewSecretMaskingHandler(handler, secrets)
return slog.New(maskedHandler), secrets
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}