feat(v2): T-308B structured camera source with encoded credentials
Config now accepts host/port/channel/username/password (untracked local config only) or rtsp_url_env; net/url percent-encodes the userinfo so a password with @/:/ builds a valid RTSP URL (fixes the manual %40 pain). Adds WriteLocalCameraSource / WriteLocalEventTuning for the settings form. Guards kept: still rejects a full url/rtsp_url; credentials stay out of the config version; a test asserts the public example has no credentials. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+122
-13
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -79,6 +80,8 @@ type rawSource struct {
|
|||||||
URL *string `json:"url"`
|
URL *string `json:"url"`
|
||||||
RTSPURL *string `json:"rtsp_url"`
|
RTSPURL *string `json:"rtsp_url"`
|
||||||
Host *string `json:"host"`
|
Host *string `json:"host"`
|
||||||
|
Port *int `json:"port"`
|
||||||
|
Channel *string `json:"channel"`
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
Transport string `json:"transport"`
|
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 {
|
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")
|
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 {
|
if raw.Source.URL != nil || raw.Source.RTSPURL != nil {
|
||||||
return Config{}, fmt.Errorf("source must define rtsp_url_env only; embedded URL and credentials are not allowed")
|
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)
|
source, err := resolveSource(*raw.Source)
|
||||||
@@ -174,14 +177,6 @@ func resolveSource(raw rawSource) (SourceConfig, error) {
|
|||||||
if strings.TrimSpace(raw.ID) == "" {
|
if strings.TrimSpace(raw.ID) == "" {
|
||||||
return SourceConfig{}, fmt.Errorf("source.id must be non-empty")
|
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))
|
transport := strings.ToLower(strings.TrimSpace(raw.Transport))
|
||||||
if transport == "" {
|
if transport == "" {
|
||||||
transport = "tcp"
|
transport = "tcp"
|
||||||
@@ -200,10 +195,124 @@ func resolveSource(raw rawSource) (SourceConfig, error) {
|
|||||||
if raw.LowLatency != nil {
|
if raw.LowLatency != nil {
|
||||||
lowLatency = *raw.LowLatency
|
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,
|
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) {
|
func resolveModel(configPath string, raw rawModel) (ModelConfig, error) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user