// Package config loads and validates the Sense process configuration. package config import ( "fmt" "net" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "time" ) const ( defaultHTTPAddress = "127.0.0.1:8080" defaultDatabaseDriver = "sqlite" postgresDatabaseDriver = "postgres" defaultDatabaseDSN = "file:data/sense.db" defaultMediaMTXURL = "http://127.0.0.1:9997" defaultReconcilePeriod = 5 * time.Second defaultReconcileLease = 30 * time.Second defaultOperationTimeout = 20 * time.Second defaultProbePeriod = 10 * time.Second defaultOrphanScanPeriod = time.Minute defaultONVIFMode = "disabled" defaultControlAuthMode = "static-sha256" defaultAuditRelayPeriod = time.Second defaultConsoleWebRTCURL = "http://127.0.0.1:8889" ) var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) type Config struct { HTTPAddress string AllowNonLoopback bool DatabaseDriver string DatabaseDSN string MediaMTXURL string ReconcileInterval time.Duration ReconcileLeaseDuration time.Duration ReconcileOperationTimeout time.Duration ProbeInterval time.Duration InstanceID string MetricsEnabled bool OrphanScanEnabled bool OrphanScanInterval time.Duration ONVIFMode string RTSPRewriteHost string RTSPRewritePort int RTSPStripQuery bool ControlAPIEnabled bool ControlAuthMode string ControlAuthFile string ControlCursorKeyFile string ControlAllowInsecureHTTP bool ConsoleEnabled bool ConsoleWebRTCBaseURL string AuditRelayEnabled bool AuditRelayURL string AuditRelayKeyFile string AuditRelayKeyID string AuditRelayInterval 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 } reconcileLease, err := durationEnv("SENSE_RECONCILE_LEASE_DURATION", defaultReconcileLease) if err != nil { return Config{}, err } operationTimeout, err := durationEnv("SENSE_RECONCILE_OPERATION_TIMEOUT", defaultOperationTimeout) 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 } consoleEnabled, err := boolEnv("SENSE_CONSOLE_ENABLED", false) if err != nil { return Config{}, err } auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false) if err != nil { return Config{}, err } auditRelayInterval, err := durationEnv("SENSE_AUDIT_RELAY_INTERVAL", defaultAuditRelayPeriod) if err != nil { return Config{}, err } metricsEnabled, err := boolEnv("SENSE_METRICS_ENABLED", true) if err != nil { return Config{}, err } databaseDriver := stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver) orphanDefault := strings.EqualFold(strings.TrimSpace(databaseDriver), postgresDatabaseDriver) orphanEnabled, err := boolEnv("SENSE_ORPHAN_SCAN_ENABLED", orphanDefault) if err != nil { return Config{}, err } orphanPeriod, err := durationEnv("SENSE_ORPHAN_SCAN_INTERVAL", defaultOrphanScanPeriod) if err != nil { return Config{}, err } cfg := Config{ HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress), AllowNonLoopback: allow, DatabaseDriver: databaseDriver, DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN), MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL), ReconcileInterval: reconcilePeriod, ReconcileLeaseDuration: reconcileLease, ReconcileOperationTimeout: operationTimeout, ProbeInterval: probePeriod, InstanceID: stringEnv("SENSE_INSTANCE_ID", ""), MetricsEnabled: metricsEnabled, OrphanScanEnabled: orphanEnabled, OrphanScanInterval: orphanPeriod, 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, ConsoleEnabled: consoleEnabled, ConsoleWebRTCBaseURL: stringEnv("SENSE_CONSOLE_WEBRTC_BASE_URL", defaultConsoleWebRTCURL), AuditRelayEnabled: auditRelayEnabled, AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""), AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""), AuditRelayKeyID: stringEnv("SENSE_AUDIT_RELAY_KEY_ID", ""), AuditRelayInterval: auditRelayInterval, } 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") } leaseDuration := c.ReconcileLeaseDuration if leaseDuration == 0 { leaseDuration = defaultReconcileLease } operationTimeout := c.ReconcileOperationTimeout if operationTimeout == 0 { operationTimeout = defaultOperationTimeout } if leaseDuration <= 0 || leaseDuration > 5*time.Minute { return fmt.Errorf("SENSE_RECONCILE_LEASE_DURATION must be positive and at most 5m") } if operationTimeout <= 0 || operationTimeout >= leaseDuration { return fmt.Errorf("SENSE_RECONCILE_OPERATION_TIMEOUT must be positive and shorter than the lease") } if c.InstanceID != "" && !instanceIDPattern.MatchString(c.InstanceID) { return fmt.Errorf("invalid SENSE_INSTANCE_ID") } if c.OrphanScanEnabled { if databaseDriver != postgresDatabaseDriver { return fmt.Errorf("orphan scanning requires SENSE_DB_DRIVER=postgres") } orphanInterval := c.OrphanScanInterval if orphanInterval == 0 { orphanInterval = defaultOrphanScanPeriod } if orphanInterval < 10*time.Second { return fmt.Errorf("SENSE_ORPHAN_SCAN_INTERVAL must be at least 10s") } } 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") } } if c.ConsoleEnabled { if !c.ControlAPIEnabled { return fmt.Errorf("Sense console requires SENSE_CONTROL_API_ENABLED=true") } if !isLoopback { return fmt.Errorf("Sense console requires an explicit loopback SENSE_HTTP_ADDR") } previewURL, err := url.Parse(c.ConsoleWebRTCBaseURL) if err != nil || previewURL.Host == "" || (previewURL.Scheme != "http" && previewURL.Scheme != "https") || previewURL.User != nil || previewURL.RawQuery != "" || previewURL.Fragment != "" || (previewURL.Path != "" && previewURL.Path != "/") { return fmt.Errorf("invalid SENSE_CONSOLE_WEBRTC_BASE_URL") } previewHost := previewURL.Hostname() previewIP := net.ParseIP(previewHost) if previewHost != "localhost" && (previewIP == nil || !previewIP.IsLoopback()) { return fmt.Errorf("SENSE_CONSOLE_WEBRTC_BASE_URL must use an explicit loopback host") } } if c.AuditRelayEnabled { if databaseDriver != postgresDatabaseDriver { return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres") } if c.AuditRelayKeyFile == "" || !filepath.IsAbs(c.AuditRelayKeyFile) { return fmt.Errorf("SENSE_AUDIT_RELAY_KEY_FILE must be an absolute external path") } if !instanceIDPattern.MatchString(c.AuditRelayKeyID) { return fmt.Errorf("invalid SENSE_AUDIT_RELAY_KEY_ID") } if c.AuditRelayInterval < time.Second { return fmt.Errorf("SENSE_AUDIT_RELAY_INTERVAL must be at least 1s") } relayURL, err := url.Parse(c.AuditRelayURL) if err != nil || relayURL.Host == "" || relayURL.Path != "/internal/v1/audit-events:batch" || relayURL.RawQuery != "" || relayURL.Fragment != "" || relayURL.User != nil { return fmt.Errorf("invalid SENSE_AUDIT_RELAY_URL") } relayHost := relayURL.Hostname() relayIP := net.ParseIP(relayHost) relayLoopback := relayHost == "localhost" || (relayIP != nil && relayIP.IsLoopback()) if relayURL.Scheme != "https" && !(relayURL.Scheme == "http" && relayLoopback) { return fmt.Errorf("SENSE_AUDIT_RELAY_URL requires HTTPS outside loopback") } } 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 }