// Package config loads and validates the Sense process configuration. package config import ( "fmt" "net" "net/url" "os" "path/filepath" "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" defaultControlAuthMode = "static-sha256" ) 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 ControlAPIEnabled bool ControlAuthMode string ControlAuthFile string ControlCursorKeyFile string ControlAllowInsecureHTTP 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 } controlEnabled, err := boolEnv("SENSE_CONTROL_API_ENABLED", false) if err != nil { return Config{}, err } controlAllowInsecure, err := boolEnv("SENSE_CONTROL_ALLOW_INSECURE_HTTP", 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, ControlAPIEnabled: controlEnabled, ControlAuthMode: stringEnv("SENSE_CONTROL_AUTH_MODE", defaultControlAuthMode), ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""), ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""), ControlAllowInsecureHTTP: controlAllowInsecure, } 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") } } if c.ControlAPIEnabled { if databaseDriver != "postgres" { return fmt.Errorf("Sense Control API requires SENSE_DB_DRIVER=postgres") } if c.ControlAuthMode != defaultControlAuthMode { return fmt.Errorf("SENSE_CONTROL_AUTH_MODE must be static-sha256") } if c.ControlAuthFile == "" || !filepath.IsAbs(c.ControlAuthFile) { return fmt.Errorf("SENSE_CONTROL_AUTH_FILE must be an absolute external path") } if c.ControlCursorKeyFile == "" || !filepath.IsAbs(c.ControlCursorKeyFile) { return fmt.Errorf("SENSE_CONTROL_CURSOR_KEY_FILE must be an absolute external path") } if !isLoopback && !c.ControlAllowInsecureHTTP { return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true") } } 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 }