// Package config loads and validates the Sense process configuration. package config import ( "fmt" "net" "net/url" "os" "strconv" "time" ) const ( defaultHTTPAddress = "127.0.0.1:8080" defaultDatabaseDSN = "file:data/sense.db" defaultMediaMTXURL = "http://127.0.0.1:9997" defaultReconcilePeriod = 5 * time.Second defaultProbePeriod = 10 * time.Second ) type Config struct { HTTPAddress string AllowNonLoopback bool DatabaseDSN string MediaMTXURL string ReconcileInterval time.Duration ProbeInterval time.Duration } func Load() (Config, error) { allow, err := boolEnv("SENSE_ALLOW_NON_LOOPBACK", false) if err != nil { return Config{}, err } reconcilePeriod, err := durationEnv("SENSE_RECONCILE_INTERVAL", defaultReconcilePeriod) if err != nil { return Config{}, err } probePeriod, err := durationEnv("SENSE_PROBE_INTERVAL", defaultProbePeriod) if err != nil { return Config{}, err } cfg := Config{ HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress), AllowNonLoopback: allow, DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN), MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL), ReconcileInterval: reconcilePeriod, ProbeInterval: probePeriod, } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func (c Config) Validate() error { host, _, err := net.SplitHostPort(c.HTTPAddress) if err != nil { return fmt.Errorf("invalid SENSE_HTTP_ADDR: %w", err) } ip := net.ParseIP(host) isLoopback := host == "localhost" || (ip != nil && ip.IsLoopback()) if !isLoopback && !c.AllowNonLoopback { return fmt.Errorf("non-loopback HTTP bind requires SENSE_ALLOW_NON_LOOPBACK=true") } if c.DatabaseDSN == "" { return fmt.Errorf("SENSE_DB_DSN must not be empty") } mediaURL, err := url.Parse(c.MediaMTXURL) if err != nil || mediaURL.Scheme == "" || mediaURL.Host == "" { return fmt.Errorf("invalid SENSE_MEDIAMTX_URL") } if mediaURL.User != nil { return fmt.Errorf("SENSE_MEDIAMTX_URL must not contain credentials") } if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 { return fmt.Errorf("loop intervals must be positive") } return nil } func stringEnv(name, fallback string) string { if value, ok := os.LookupEnv(name); ok { return value } return fallback } func boolEnv(name string, fallback bool) (bool, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := strconv.ParseBool(value) if err != nil { return false, fmt.Errorf("invalid %s: %w", name, err) } return parsed, nil } func durationEnv(name string, fallback time.Duration) (time.Duration, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := time.ParseDuration(value) if err != nil { return 0, fmt.Errorf("invalid %s: %w", name, err) } return parsed, nil }