From 70d9c5c73e0eb1582573afb4aa8e318a59295939 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Wed, 22 Jul 2026 21:13:03 +0800 Subject: [PATCH] feat(v2): add safe runtime configuration --- docs/04-architecture.md | 1 + docs/api.md | 9 + .../plans/2026-07-22-t304-go-demo-delivery.md | 10 +- v2/assets/runtime-manifest.example.json | 6 + v2/config.example.json | 32 ++ v2/internal/config/config.go | 300 ++++++++++++++++++ v2/internal/config/config_test.go | 104 ++++++ 7 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 v2/assets/runtime-manifest.example.json create mode 100644 v2/config.example.json create mode 100644 v2/internal/config/config.go create mode 100644 v2/internal/config/config_test.go diff --git a/docs/04-architecture.md b/docs/04-architecture.md index a775b5d..ddb39eb 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -53,6 +53,7 @@ V2 对应模块的当前落实与 T-304 目标如下: | 模块 | V2 目标位置 | 职责 | 不负责 | | --- | --- | --- | --- | | 回放解码器 | `v2/cmd/regression` | 用受控 FFmpeg 顺序读取 BGR 帧,供本机录像回归 | RTSP 重连、UI 绘制 | +| 运行配置 | `v2/internal/config` | 校验公开 JSON、仅从环境变量解析 RTSP URL、生成非敏感配置版本 | 保存或显示 URL、账号、密码 | | ONNX Pose | `v2/internal/pose` | 114 letterbox、RGB/CHW、NMS、关键点及坐标还原 | 人员 ID、摔倒结论 | | 事件引擎 | `v2/internal/fall` | 复现 V1 的跟踪、证据、四态状态机和 `FallEvent` | 视频解码、声音、文件 | | Windows UI | `v2/internal/ui` | Walk 顶部“监控/设置”Tab、渲染最新帧和已计算状态 | 直接读 RTSP、执行 ONNX 或事件规则 | diff --git a/docs/api.md b/docs/api.md index af07e68..6db4ffb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -42,6 +42,15 @@ - 灵敏度参数(默认放宽,适配低位俯视):`require_rapid_drop`(默认 false,持续水平即可进入疑似,不再强制先检测到快速下移);`require_lower_body`(默认 false,质量门控只强制肩+髋,膝踝可缺);`horizontal_angle_threshold_degrees`(默认 45,躯干与水平夹角≤此值算水平)。放宽后由 `confirm_window_seconds` 作为主要防误报闸——弯腰、捡物不会持续水平到确认窗满。三者进入 `config_version`,可经设置调整并追溯。 - 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。 +### V2 Go 运行配置 + +`v2/config.example.json` 是 V2 的公开配置形状。它保留 V1 的来源、模型、事件和工件语义,但将 V2 交付依赖显式写入 `model.onnx`、`model.ort_dll` 和 `tools.ffmpeg`/`tools.ffprobe`: + +- `source` 只接受 `id`、`rtsp_url_env`、`transport`、`timeout_seconds` 和 `low_latency`。任何 `url`、`rtsp_url`、`host`、`username` 或 `password` 字段都会被拒绝,即使它们为空;实际流地址只从指定环境变量进入进程内存。 +- `model.sha256` 必须是锁定 ONNX 的 64 位十六进制 SHA-256;`confidence_threshold` 是 Pose 框过滤阈值。配置相对路径相对于配置文件解析,但只在启动预检时要求实际文件存在。 +- `event` 字段与 V1 的默认事件语义相同。V2 使用 `source.id`、模型 SHA、模型置信度和这些事件字段生成 `cfg-`;URL、工具路径和工件路径均不进入该版本值。 +- `artifacts.event_dir` 只保存本地 PNG/JSONL 事件证据。V2 发布脚本生成的 `runtime-manifest.json` 记录 ONNX Runtime、FFmpeg、FFprobe 和 ONNX 的来源文件哈希;示例 `runtime-manifest.example.json` 不是交付证据。 + ## 核心数据 ```text diff --git a/docs/superpowers/plans/2026-07-22-t304-go-demo-delivery.md b/docs/superpowers/plans/2026-07-22-t304-go-demo-delivery.md index 0afc87d..7d5c978 100644 --- a/docs/superpowers/plans/2026-07-22-t304-go-demo-delivery.md +++ b/docs/superpowers/plans/2026-07-22-t304-go-demo-delivery.md @@ -41,7 +41,7 @@ - Create: `v2/assets/runtime-manifest.example.json` - Modify: `docs/api.md` -- [ ] **Step 1: Write failing configuration tests** +- [x] **Step 1: Write failing configuration tests** ```go func TestLoadResolvesOnlyNamedEnvironmentURL(t *testing.T) { @@ -56,12 +56,12 @@ func TestLoadRejectsEmbeddedRTSPURL(t *testing.T) { } ``` -- [ ] **Step 2: Run the focused test and verify the missing package fails** +- [x] **Step 2: Run the focused test and verify the missing package fails** Run: `Set-Location v2; go test ./internal/config -run TestLoad -v` Expected: FAIL because `internal/config` does not exist. -- [ ] **Step 3: Implement the minimal public contract** +- [x] **Step 3: Implement the minimal public contract** ```go type Config struct { @@ -79,12 +79,12 @@ func Load(path string) (Config, error) { Validate all required files and 64-hex model hash before monitoring starts. Return generic path/configuration errors; do not include the resolved URL in errors. Document the V2 field meanings and unchanged `FallEvent` fields in `docs/api.md`. -- [ ] **Step 4: Re-run focused and package tests** +- [x] **Step 4: Re-run focused and package tests** Run: `Set-Location v2; go test ./internal/config -v` Expected: PASS, including missing environment, embedded URL, invalid hash and version-redaction cases. -- [ ] **Step 5: Commit the configuration slice** +- [x] **Step 5: Commit the configuration slice** ```powershell git add v2/config.example.json v2/internal/config v2/assets/runtime-manifest.example.json docs/api.md diff --git a/v2/assets/runtime-manifest.example.json b/v2/assets/runtime-manifest.example.json new file mode 100644 index 0000000..43146df --- /dev/null +++ b/v2/assets/runtime-manifest.example.json @@ -0,0 +1,6 @@ +{ + "format": "silver-pose-v2-runtime-1", + "generated_by": "v2/scripts/build-v2-release.ps1", + "note": "A release manifest is generated from explicit local runtime inputs and records their SHA-256 values. This example is not release evidence.", + "files": [] +} diff --git a/v2/config.example.json b/v2/config.example.json new file mode 100644 index 0000000..1c79952 --- /dev/null +++ b/v2/config.example.json @@ -0,0 +1,32 @@ +{ + "source": { + "id": "lobby-camera-01", + "rtsp_url_env": "SILVER_POSE_RTSP_URL", + "transport": "tcp", + "timeout_seconds": 5.0, + "low_latency": true + }, + "model": { + "onnx": "runtime/best.onnx", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "ort_dll": "runtime/onnxruntime.dll", + "confidence_threshold": 0.25 + }, + "tools": { + "ffmpeg": "runtime/ffmpeg.exe", + "ffprobe": "runtime/ffprobe.exe" + }, + "event": { + "keypoint_confidence_threshold": 0.4, + "suspect_window_seconds": 0.5, + "confirm_window_seconds": 1.8, + "recovery_window_seconds": 2.0, + "cooldown_seconds": 10.0, + "require_rapid_drop": false, + "require_lower_body": false, + "horizontal_angle_threshold_degrees": 45.0 + }, + "artifacts": { + "event_dir": "../artifacts/events" + } +} diff --git a/v2/internal/config/config.go b/v2/internal/config/config.go new file mode 100644 index 0000000..f0fe0ec --- /dev/null +++ b/v2/internal/config/config.go @@ -0,0 +1,300 @@ +// Package config loads the credential-safe V2 runtime configuration. +package config + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "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 + 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"` + 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 || 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") + } + + 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") + } + 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" + } + 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 + } + return SourceConfig{ + ID: strings.TrimSpace(raw.ID), URL: url, RTSPURLEnv: name, Transport: transport, + Timeout: time.Duration(timeout * float64(time.Second)), LowLatency: lowLatency, + }, nil +} + +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 +} diff --git a/v2/internal/config/config_test.go b/v2/internal/config/config_test.go new file mode 100644 index 0000000..ffe5e7b --- /dev/null +++ b/v2/internal/config/config_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const testSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func TestLoadResolvesOnlyNamedEnvironmentURL(t *testing.T) { + t.Setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@192.0.2.9/Streaming/Channels/101") + + cfg, err := Load(writeConfig(t, validConfigJSON())) + if err != nil { + t.Fatal(err) + } + if cfg.Source.URL != "rtsp://operator:secret@192.0.2.9/Streaming/Channels/101" { + t.Fatalf("resolved source URL = %q", cfg.Source.URL) + } + if !strings.HasPrefix(cfg.RuntimeConfigVersion, "cfg-") { + t.Fatalf("runtime config version = %q", cfg.RuntimeConfigVersion) + } + if strings.Contains(cfg.RuntimeConfigVersion, "secret") { + t.Fatalf("runtime config version leaked a credential: %q", cfg.RuntimeConfigVersion) + } + if !filepath.IsAbs(cfg.Model.ONNXPath) || !filepath.IsAbs(cfg.Tools.FFmpegPath) { + t.Fatalf("relative paths were not resolved: %#v", cfg) + } +} + +func TestLoadRejectsEmbeddedRTSPURL(t *testing.T) { + t.Setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@192.0.2.9/Streaming/Channels/101") + configJSON := strings.Replace(validConfigJSON(), `"rtsp_url_env": "SILVER_POSE_RTSP_URL"`, `"rtsp_url_env": "SILVER_POSE_RTSP_URL", "url": "rtsp://secret"`, 1) + + _, err := Load(writeConfig(t, configJSON)) + if err == nil || !strings.Contains(err.Error(), "rtsp_url_env") { + t.Fatalf("embedded URL error = %v", err) + } + if strings.Contains(err.Error(), "secret") { + t.Fatalf("embedded URL error leaked a credential: %v", err) + } +} + +func TestLoadRejectsMissingSourceEnvironment(t *testing.T) { + os.Unsetenv("SILVER_POSE_RTSP_URL") + + _, err := Load(writeConfig(t, validConfigJSON())) + if err == nil || !strings.Contains(err.Error(), "SILVER_POSE_RTSP_URL") { + t.Fatalf("missing environment error = %v", err) + } +} + +func TestLoadRejectsInvalidModelHash(t *testing.T) { + t.Setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@192.0.2.9/Streaming/Channels/101") + + _, err := Load(writeConfig(t, strings.Replace(validConfigJSON(), testSHA256, "invalid", 1))) + if err == nil || !strings.Contains(err.Error(), "model.sha256") { + t.Fatalf("invalid hash error = %v", err) + } +} + +func writeConfig(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 validConfigJSON() string { + return `{ + "source": { + "id": "lobby-camera-01", + "rtsp_url_env": "SILVER_POSE_RTSP_URL", + "transport": "tcp", + "timeout_seconds": 5, + "low_latency": true + }, + "model": { + "onnx": "assets/best.onnx", + "sha256": "` + testSHA256 + `", + "ort_dll": "runtime/onnxruntime.dll", + "confidence_threshold": 0.25 + }, + "tools": { + "ffmpeg": "runtime/ffmpeg.exe", + "ffprobe": "runtime/ffprobe.exe" + }, + "event": { + "keypoint_confidence_threshold": 0.4, + "suspect_window_seconds": 0.5, + "confirm_window_seconds": 1.8, + "recovery_window_seconds": 2, + "cooldown_seconds": 10, + "require_rapid_drop": false, + "require_lower_body": false, + "horizontal_angle_threshold_degrees": 45 + }, + "artifacts": { "event_dir": "../artifacts/events" } +}` +}