diff --git a/v2/internal/config/config.go b/v2/internal/config/config.go index f0fe0ec..2c02d1f 100644 --- a/v2/internal/config/config.go +++ b/v2/internal/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net/url" "os" "path/filepath" "regexp" @@ -79,6 +80,8 @@ type rawSource struct { 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"` @@ -131,8 +134,8 @@ func Load(path string) (Config, error) { 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 || raw.Source.Host != nil || raw.Source.Username != nil || raw.Source.Password != nil { - return Config{}, fmt.Errorf("source must define rtsp_url_env only; embedded URL and credentials are not allowed") + 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) @@ -174,14 +177,6 @@ func resolveSource(raw rawSource) (SourceConfig, error) { if strings.TrimSpace(raw.ID) == "" { return SourceConfig{}, fmt.Errorf("source.id must be non-empty") } - name := strings.TrimSpace(raw.RTSPURLEnv) - if !envPattern.MatchString(name) { - return SourceConfig{}, fmt.Errorf("source.rtsp_url_env must be an environment variable name") - } - url, found := os.LookupEnv(name) - if !found || strings.TrimSpace(url) == "" { - return SourceConfig{}, fmt.Errorf("missing source environment variable: %s", name) - } transport := strings.ToLower(strings.TrimSpace(raw.Transport)) if transport == "" { transport = "tcp" @@ -200,10 +195,124 @@ func resolveSource(raw rawSource) (SourceConfig, error) { if raw.LowLatency != nil { lowLatency = *raw.LowLatency } - return SourceConfig{ - ID: strings.TrimSpace(raw.ID), URL: url, RTSPURLEnv: name, Transport: transport, + + resolved := SourceConfig{ + ID: strings.TrimSpace(raw.ID), Transport: transport, Timeout: time.Duration(timeout * float64(time.Second)), LowLatency: lowLatency, - }, nil + } + + if raw.Host != nil { + streamURL, err := structuredSourceURL(raw) + if err != nil { + return SourceConfig{}, err + } + resolved.URL = streamURL + 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 +} + +// structuredSourceURL builds an RTSP URL from local host/credential fields. +// net/url percent-encodes the userinfo, so a password with @/:/ does not break +// parsing. These fields are only permitted in the untracked local config. +func structuredSourceURL(raw rawSource) (string, error) { + host := strings.TrimSpace(*raw.Host) + if host == "" { + return "", fmt.Errorf("source.host must be non-empty") + } + if raw.Username == nil || strings.TrimSpace(*raw.Username) == "" { + return "", fmt.Errorf("source.username must be non-empty") + } + if raw.Password == nil || *raw.Password == "" { + return "", fmt.Errorf("source.password must be non-empty") + } + port := 554 + if raw.Port != nil { + port = *raw.Port + } + if port < 1 || port > 65535 { + return "", 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) + } + return buildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel), 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) { diff --git a/v2/internal/config/config_structured_test.go b/v2/internal/config/config_structured_test.go new file mode 100644 index 0000000..2e45e66 --- /dev/null +++ b/v2/internal/config/config_structured_test.go @@ -0,0 +1,140 @@ +package config + +import ( + "encoding/json" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTempConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func structuredConfigJSON() string { + return strings.Replace(validConfigJSON(), + `"rtsp_url_env": "SILVER_POSE_RTSP_URL",`, + `"host": "192.0.2.10", "port": 554, "channel": "102", "username": "admin", "password": "p@ss/w:d",`, + 1) +} + +func TestStructuredSourceBuildsLosslessEncodedURL(t *testing.T) { + cfg, err := Load(writeTempConfig(t, structuredConfigJSON())) + if err != nil { + t.Fatalf("load: %v", err) + } + if strings.Contains(cfg.Source.URL, "p@ss") { + t.Fatalf("password @ was not percent-encoded: %q", cfg.Source.URL) + } + parsed, err := url.Parse(cfg.Source.URL) + if err != nil { + t.Fatalf("built URL does not parse: %v", err) + } + if parsed.Hostname() != "192.0.2.10" || parsed.Port() != "554" { + t.Fatalf("host/port = %q/%q", parsed.Hostname(), parsed.Port()) + } + if parsed.User.Username() != "admin" { + t.Fatalf("username = %q", parsed.User.Username()) + } + if password, _ := parsed.User.Password(); password != "p@ss/w:d" { + t.Fatalf("password did not round-trip: %q", password) + } + if parsed.Path != "/Streaming/Channels/102" { + t.Fatalf("path = %q", parsed.Path) + } +} + +func TestStructuredSourceRequiresPassword(t *testing.T) { + noPassword := strings.Replace(structuredConfigJSON(), `"password": "p@ss/w:d",`, "", 1) + if _, err := Load(writeTempConfig(t, noPassword)); err == nil || !strings.Contains(err.Error(), "password") { + t.Fatalf("expected password error, got %v", err) + } +} + +func TestStructuredCredentialsExcludedFromConfigVersion(t *testing.T) { + first, err := Load(writeTempConfig(t, structuredConfigJSON())) + if err != nil { + t.Fatal(err) + } + other := strings.Replace(structuredConfigJSON(), `"password": "p@ss/w:d"`, `"password": "different"`, 1) + second, err := Load(writeTempConfig(t, other)) + if err != nil { + t.Fatal(err) + } + if first.Source.URL == second.Source.URL { + t.Fatal("test setup did not change the credential") + } + if first.RuntimeConfigVersion != second.RuntimeConfigVersion { + t.Fatal("runtime config version changed with a credential") + } +} + +func TestPublicExampleHasNoEmbeddedCredentials(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "config.example.json")) + if err != nil { + t.Fatal(err) + } + var document struct { + Source map[string]json.RawMessage `json:"source"` + } + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"host", "username", "password", "url", "rtsp_url"} { + if _, present := document.Source[forbidden]; present { + t.Fatalf("public example leaked credential field %q", forbidden) + } + } + if _, present := document.Source["rtsp_url_env"]; !present { + t.Fatal("public example missing rtsp_url_env") + } +} + +func TestWriteLocalCameraSourceRoundTrips(t *testing.T) { + path := writeTempConfig(t, validConfigJSON()) + if err := WriteLocalCameraSource(path, "hik", "192.0.2.20", 554, "102", "operator", "s@cret", "tcp", 5, true); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if cfg.Source.ID != "hik" { + t.Fatalf("id = %q", cfg.Source.ID) + } + parsed, _ := url.Parse(cfg.Source.URL) + if parsed.Hostname() != "192.0.2.20" { + t.Fatalf("host = %q", parsed.Hostname()) + } + if password, _ := parsed.User.Password(); password != "s@cret" { + t.Fatalf("password = %q", password) + } +} + +func TestWriteLocalEventTuningRoundTrips(t *testing.T) { + path := writeTempConfig(t, validConfigJSON()) + t.Setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live") + if err := WriteLocalEventTuning(path, 0.5, 2.5, 35, 0.3, true, true); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if cfg.Event.ConfirmWindowSeconds != 2.5 || cfg.Event.HorizontalAngleThresholdDegrees != 35 { + t.Fatalf("event tuning not applied: %+v", cfg.Event) + } + if !cfg.Event.RequireRapidDrop || !cfg.Event.RequireLowerBody { + t.Fatalf("boolean tuning not applied: %+v", cfg.Event) + } + if cfg.Model.ConfidenceThreshold != 0.3 { + t.Fatalf("model confidence = %v", cfg.Model.ConfidenceThreshold) + } +}