// Package config loads and validates the Sense process configuration. package config import ( "fmt" "net" "net/url" "os" "strconv" "strings" "time" ) const ( defaultHTTPAddress = "127.0.0.1:8080" defaultDatabaseDriver = "sqlite" defaultDatabaseDSN = "file:data/sense.db" defaultMediaMTXURL = "http://127.0.0.1:9997" defaultReconcilePeriod = 5 * time.Second defaultProbePeriod = 10 * time.Second defaultONVIFMode = "disabled" ) type Config struct { HTTPAddress string AllowNonLoopback bool DatabaseDriver string DatabaseDSN string MediaMTXURL string ReconcileInterval time.Duration ProbeInterval time.Duration ONVIFMode string RTSPRewriteHost string RTSPRewritePort int RTSPStripQuery bool } 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 } rewritePort, err := intEnv("SENSE_ONVIF_RTSP_REWRITE_PORT", 0) if err != nil { return Config{}, err } stripQuery, err := boolEnv("SENSE_ONVIF_RTSP_STRIP_QUERY", false) if err != nil { return Config{}, err } cfg := Config{ HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress), AllowNonLoopback: allow, DatabaseDriver: stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver), DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN), MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL), ReconcileInterval: reconcilePeriod, ProbeInterval: probePeriod, ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode), RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""), RTSPRewritePort: rewritePort, RTSPStripQuery: stripQuery, } 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") } databaseDriver := strings.ToLower(strings.TrimSpace(c.DatabaseDriver)) if databaseDriver == "" { databaseDriver = defaultDatabaseDriver } if databaseDriver != "sqlite" && databaseDriver != "postgres" { return fmt.Errorf("SENSE_DB_DRIVER must be sqlite or postgres") } if databaseDriver == "postgres" && strings.HasPrefix(c.DatabaseDSN, "file:") { return fmt.Errorf("postgres SENSE_DB_DRIVER requires an explicit PostgreSQL SENSE_DB_DSN") } 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") } if c.ONVIFMode != "" && c.ONVIFMode != "disabled" && c.ONVIFMode != "standard" { return fmt.Errorf("SENSE_ONVIF_MODE must be disabled or standard") } if c.RTSPRewritePort < 0 || c.RTSPRewritePort > 65535 { return fmt.Errorf("SENSE_ONVIF_RTSP_REWRITE_PORT must be between 0 and 65535") } if c.RTSPRewriteHost != "" { if strings.TrimSpace(c.RTSPRewriteHost) != c.RTSPRewriteHost || strings.ContainsAny(c.RTSPRewriteHost, "/@") { return fmt.Errorf("invalid SENSE_ONVIF_RTSP_REWRITE_HOST") } } 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 } func intEnv(name string, fallback int) (int, error) { value, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := strconv.Atoi(value) if err != nil { return 0, fmt.Errorf("invalid %s: %w", name, err) } return parsed, nil }