// Package config loads the credential-safe V2 runtime configuration. package config import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/url" "os" "path/filepath" "regexp" "strings" "time" ) var ( sha256Pattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) envPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) ) // Config is the validated V2 runtime configuration. Source.URL is resolved // only in memory from an environment variable and must never be logged. type Config struct { Source SourceConfig Model ModelConfig Tools ToolConfig Event EventConfig Artifacts ArtifactConfig RuntimeConfigVersion string } type SourceConfig struct { ID string URL string RTSPURLEnv string Host string Port int Channel string Transport string Timeout time.Duration LowLatency bool } type ModelConfig struct { ONNXPath string SHA256 string ONNXRuntimeDLLPath string ConfidenceThreshold float32 } type ToolConfig struct { FFmpegPath string FFprobePath string } type EventConfig struct { KeypointConfidenceThreshold float32 SuspectWindowSeconds float64 ConfirmWindowSeconds float64 RecoveryWindowSeconds float64 CooldownSeconds float64 RequireRapidDrop bool RequireLowerBody bool HorizontalAngleThresholdDegrees float32 } type ArtifactConfig struct { EventDirectory string } type rawConfig struct { Source *rawSource `json:"source"` Model *rawModel `json:"model"` Tools *rawTools `json:"tools"` Event *rawEvent `json:"event"` Artifacts *rawArtifacts `json:"artifacts"` } type rawSource struct { ID string `json:"id"` RTSPURLEnv string `json:"rtsp_url_env"` URL *string `json:"url"` RTSPURL *string `json:"rtsp_url"` Host *string `json:"host"` Port *int `json:"port"` Channel *string `json:"channel"` Username *string `json:"username"` Password *string `json:"password"` Transport string `json:"transport"` TimeoutSeconds float64 `json:"timeout_seconds"` LowLatency *bool `json:"low_latency"` } type rawModel struct { ONNX string `json:"onnx"` SHA256 string `json:"sha256"` ONNXRuntimeDLL string `json:"ort_dll"` ConfidenceThreshold float32 `json:"confidence_threshold"` } type rawTools struct { FFmpeg string `json:"ffmpeg"` FFprobe string `json:"ffprobe"` } type rawEvent struct { KeypointConfidenceThreshold float32 `json:"keypoint_confidence_threshold"` SuspectWindowSeconds float64 `json:"suspect_window_seconds"` ConfirmWindowSeconds float64 `json:"confirm_window_seconds"` RecoveryWindowSeconds float64 `json:"recovery_window_seconds"` CooldownSeconds float64 `json:"cooldown_seconds"` RequireRapidDrop bool `json:"require_rapid_drop"` RequireLowerBody bool `json:"require_lower_body"` HorizontalAngleThresholdDegrees float32 `json:"horizontal_angle_threshold_degrees"` } type rawArtifacts struct { EventDirectory string `json:"event_dir"` } // Load reads a public/local V2 config. It rejects embedded URLs and credentials, // then resolves the configured source environment variable in memory. func Load(path string) (Config, error) { configPath, err := filepath.Abs(path) if err != nil { return Config{}, fmt.Errorf("resolve config path: %w", err) } data, err := os.ReadFile(configPath) if err != nil { return Config{}, fmt.Errorf("read config: %w", err) } var raw rawConfig if err := json.Unmarshal(data, &raw); err != nil { return Config{}, fmt.Errorf("parse config: %w", err) } if raw.Source == nil || raw.Model == nil || raw.Tools == nil || raw.Event == nil || raw.Artifacts == nil { return Config{}, fmt.Errorf("config requires source, model, tools, event and artifacts objects") } if raw.Source.URL != nil || raw.Source.RTSPURL != nil { return Config{}, fmt.Errorf("source must define structured host fields or rtsp_url_env, not a full url or rtsp_url") } source, err := resolveSource(*raw.Source) if err != nil { return Config{}, err } model, err := resolveModel(configPath, *raw.Model) if err != nil { return Config{}, err } tools, err := resolveTools(configPath, *raw.Tools) if err != nil { return Config{}, err } event, err := resolveEvent(*raw.Event) if err != nil { return Config{}, err } eventDirectory, err := resolvePath(configPath, raw.Artifacts.EventDirectory, "artifacts.event_dir") if err != nil { return Config{}, err } config := Config{ Source: source, Model: model, Tools: tools, Event: event, Artifacts: ArtifactConfig{EventDirectory: eventDirectory}, } config.RuntimeConfigVersion, err = runtimeConfigVersion(config) if err != nil { return Config{}, err } return config, nil } func resolveSource(raw rawSource) (SourceConfig, error) { if strings.TrimSpace(raw.ID) == "" { return SourceConfig{}, fmt.Errorf("source.id must be non-empty") } transport := strings.ToLower(strings.TrimSpace(raw.Transport)) if transport == "" { transport = "tcp" } if transport != "tcp" && transport != "udp" { return SourceConfig{}, fmt.Errorf("source.transport must be tcp or udp") } timeout := raw.TimeoutSeconds if timeout == 0 { timeout = 5 } if timeout < 0 || timeout > 60 { return SourceConfig{}, fmt.Errorf("source.timeout_seconds must be between 0 and 60") } lowLatency := true if raw.LowLatency != nil { lowLatency = *raw.LowLatency } resolved := SourceConfig{ ID: strings.TrimSpace(raw.ID), Transport: transport, Timeout: time.Duration(timeout * float64(time.Second)), LowLatency: lowLatency, } if raw.Host != nil { host := strings.TrimSpace(*raw.Host) if host == "" { return SourceConfig{}, fmt.Errorf("source.host must be non-empty") } if raw.Username == nil || strings.TrimSpace(*raw.Username) == "" { return SourceConfig{}, fmt.Errorf("source.username must be non-empty") } if raw.Password == nil || *raw.Password == "" { return SourceConfig{}, fmt.Errorf("source.password must be non-empty") } port := 554 if raw.Port != nil { port = *raw.Port } if port < 1 || port > 65535 { return SourceConfig{}, fmt.Errorf("source.port must be between 1 and 65535") } channel := "101" if raw.Channel != nil && strings.TrimSpace(*raw.Channel) != "" { channel = strings.TrimSpace(*raw.Channel) } resolved.Host = host resolved.Port = port resolved.Channel = channel resolved.URL = BuildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel) return resolved, nil } name := strings.TrimSpace(raw.RTSPURLEnv) if !envPattern.MatchString(name) { return SourceConfig{}, fmt.Errorf("source.rtsp_url_env must be an environment variable name") } streamURL, found := os.LookupEnv(name) if !found || strings.TrimSpace(streamURL) == "" { return SourceConfig{}, fmt.Errorf("missing source environment variable: %s", name) } resolved.URL = streamURL resolved.RTSPURLEnv = name return resolved, nil } // BuildRTSPURL assembles a Hikvision RTSP URL with percent-encoded credentials. func BuildRTSPURL(host string, port int, username, password, channel string) string { address := url.URL{ Scheme: "rtsp", User: url.UserPassword(username, password), Host: fmt.Sprintf("%s:%d", host, port), Path: "/Streaming/Channels/" + channel, } return address.String() } // updateLocalConfig rewrites one untracked local config file, preserving blocks // the caller does not touch. Only ever call this on config.local.json. func updateLocalConfig(configPath string, mutate func(map[string]interface{})) error { document := map[string]interface{}{} if data, err := os.ReadFile(configPath); err == nil { if err := json.Unmarshal(data, &document); err != nil { return fmt.Errorf("parse local config: %w", err) } } mutate(document) out, err := json.MarshalIndent(document, "", " ") if err != nil { return fmt.Errorf("encode local config: %w", err) } return os.WriteFile(configPath, append(out, '\n'), 0o600) } func objectOf(document map[string]interface{}, key string) map[string]interface{} { if existing, ok := document[key].(map[string]interface{}); ok { return existing } created := map[string]interface{}{} document[key] = created return created } // WriteLocalCameraSource persists a structured camera source (with plaintext // credentials) into the untracked local config. The public example must never // receive these fields. func WriteLocalCameraSource(configPath, id, host string, port int, channel, username, password, transport string, timeoutSeconds float64, lowLatency bool) error { return updateLocalConfig(configPath, func(document map[string]interface{}) { document["source"] = map[string]interface{}{ "id": id, "host": host, "port": port, "channel": channel, "username": username, "password": password, "transport": transport, "timeout_seconds": timeoutSeconds, "low_latency": lowLatency, } }) } // WriteLocalEventTuning persists the common detection parameters into the // untracked local config, preserving other event/model fields. func WriteLocalEventTuning(configPath string, keypointConfidence, confirmWindowSeconds, horizontalAngleDegrees, modelConfidence float64, requireRapidDrop, requireLowerBody bool) error { return updateLocalConfig(configPath, func(document map[string]interface{}) { event := objectOf(document, "event") event["keypoint_confidence_threshold"] = keypointConfidence event["confirm_window_seconds"] = confirmWindowSeconds event["horizontal_angle_threshold_degrees"] = horizontalAngleDegrees event["require_rapid_drop"] = requireRapidDrop event["require_lower_body"] = requireLowerBody objectOf(document, "model")["confidence_threshold"] = modelConfidence }) } func resolveModel(configPath string, raw rawModel) (ModelConfig, error) { if !sha256Pattern.MatchString(raw.SHA256) { return ModelConfig{}, fmt.Errorf("model.sha256 must be a 64-character SHA-256 value") } if raw.ConfidenceThreshold < 0 || raw.ConfidenceThreshold > 1 { return ModelConfig{}, fmt.Errorf("model.confidence_threshold must be between 0 and 1") } onnxPath, err := resolvePath(configPath, raw.ONNX, "model.onnx") if err != nil { return ModelConfig{}, err } dllPath, err := resolvePath(configPath, raw.ONNXRuntimeDLL, "model.ort_dll") if err != nil { return ModelConfig{}, err } return ModelConfig{ ONNXPath: onnxPath, SHA256: strings.ToLower(raw.SHA256), ONNXRuntimeDLLPath: dllPath, ConfidenceThreshold: raw.ConfidenceThreshold, }, nil } func resolveTools(configPath string, raw rawTools) (ToolConfig, error) { ffmpegPath, err := resolvePath(configPath, raw.FFmpeg, "tools.ffmpeg") if err != nil { return ToolConfig{}, err } ffprobePath, err := resolvePath(configPath, raw.FFprobe, "tools.ffprobe") if err != nil { return ToolConfig{}, err } return ToolConfig{FFmpegPath: ffmpegPath, FFprobePath: ffprobePath}, nil } func resolveEvent(raw rawEvent) (EventConfig, error) { if raw.KeypointConfidenceThreshold < 0 || raw.KeypointConfidenceThreshold > 1 { return EventConfig{}, fmt.Errorf("event.keypoint_confidence_threshold must be between 0 and 1") } if raw.SuspectWindowSeconds < 0 || raw.SuspectWindowSeconds > 30 { return EventConfig{}, fmt.Errorf("event.suspect_window_seconds must be between 0 and 30") } if raw.ConfirmWindowSeconds < 1 || raw.ConfirmWindowSeconds > 3 { return EventConfig{}, fmt.Errorf("event.confirm_window_seconds must be between 1 and 3") } if raw.RecoveryWindowSeconds <= 0 || raw.RecoveryWindowSeconds > 300 { return EventConfig{}, fmt.Errorf("event.recovery_window_seconds must be between 0 and 300") } if raw.CooldownSeconds < 0 || raw.CooldownSeconds > 3600 { return EventConfig{}, fmt.Errorf("event.cooldown_seconds must be between 0 and 3600") } if raw.HorizontalAngleThresholdDegrees < 0 || raw.HorizontalAngleThresholdDegrees > 90 { return EventConfig{}, fmt.Errorf("event.horizontal_angle_threshold_degrees must be between 0 and 90") } return EventConfig{ KeypointConfidenceThreshold: raw.KeypointConfidenceThreshold, SuspectWindowSeconds: raw.SuspectWindowSeconds, ConfirmWindowSeconds: raw.ConfirmWindowSeconds, RecoveryWindowSeconds: raw.RecoveryWindowSeconds, CooldownSeconds: raw.CooldownSeconds, RequireRapidDrop: raw.RequireRapidDrop, RequireLowerBody: raw.RequireLowerBody, HorizontalAngleThresholdDegrees: raw.HorizontalAngleThresholdDegrees, }, nil } func resolvePath(configPath, value, field string) (string, error) { if strings.TrimSpace(value) == "" { return "", fmt.Errorf("%s must be a non-empty path", field) } path := filepath.Clean(value) if !filepath.IsAbs(path) { path = filepath.Join(filepath.Dir(configPath), path) } return filepath.Abs(path) } func runtimeConfigVersion(config Config) (string, error) { payload := struct { SourceID string `json:"source_id"` ModelSHA256 string `json:"model_sha256"` ConfidenceThreshold float32 `json:"confidence_threshold"` Event EventConfig `json:"event"` }{ SourceID: config.Source.ID, ModelSHA256: config.Model.SHA256, ConfidenceThreshold: config.Model.ConfidenceThreshold, Event: config.Event, } canonical, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("encode runtime config version: %w", err) } digest := sha256.Sum256(canonical) return "cfg-" + hex.EncodeToString(digest[:]), nil }