From 440831fb4dfa8b57e44c98d7ebd9365601e8dde9 Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:24 +0800
Subject: [PATCH 1/9] fix: support ONVIF Digest media services (#48)
---
.../server/app/sense/adapters/onvif/client.go | 196 +++++++++++++++++-
1 file changed, 193 insertions(+), 3 deletions(-)
diff --git a/Sense/server/app/sense/adapters/onvif/client.go b/Sense/server/app/sense/adapters/onvif/client.go
index c03cb87..ffae991 100644
--- a/Sense/server/app/sense/adapters/onvif/client.go
+++ b/Sense/server/app/sense/adapters/onvif/client.go
@@ -3,11 +3,15 @@ package onvif
import (
"bytes"
"context"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/sha256"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
+ "strconv"
"strings"
"time"
)
@@ -34,10 +38,28 @@ func NewHTTPClient(timeout time.Duration) *HTTPClient {
if timeout <= 0 {
timeout = 8 * time.Second
}
- return &HTTPClient{client: &http.Client{Timeout: timeout}}
+ return &HTTPClient{client: &http.Client{
+ Timeout: timeout,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }}
}
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
- endpoint, err := validateEndpoint(address)
+ deviceEndpoint, err := validateEndpoint(address)
+ if err != nil {
+ return nil, err
+ }
+ capabilitiesBody := `All`
+ capabilities, err := c.soap(ctx, deviceEndpoint, credential, capabilitiesBody)
+ if err != nil {
+ return nil, err
+ }
+ mediaAddress, err := ParseMediaServiceAddress(capabilities)
+ if err != nil {
+ return nil, err
+ }
+ endpoint, err := normalizeServiceEndpoint(deviceEndpoint, mediaAddress)
if err != nil {
return nil, err
}
@@ -64,12 +86,18 @@ func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Cr
return profiles, nil
}
func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) {
+ return c.soapAttempt(ctx, endpoint, credential, body, "")
+}
+
+func (c *HTTPClient) soapAttempt(ctx context.Context, endpoint string, credential Credential, body, authorization string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
- if credential.Username != "" {
+ if authorization != "" {
+ req.Header.Set("Authorization", authorization)
+ } else if credential.Username != "" {
req.SetBasicAuth(credential.Username, credential.Password)
}
res, err := c.client.Do(req)
@@ -82,6 +110,16 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
return nil, err
}
if res.StatusCode == http.StatusUnauthorized {
+ if authorization == "" && credential.Username != "" {
+ challenge, challengeErr := parseDigestChallenge(res.Header.Values("WWW-Authenticate"))
+ if challengeErr == nil {
+ digest, digestErr := digestAuthorization(http.MethodPost, req.URL.RequestURI(), credential, challenge)
+ if digestErr != nil {
+ return nil, digestErr
+ }
+ return c.soapAttempt(ctx, endpoint, credential, body, digest)
+ }
+ }
return nil, fmt.Errorf("authentication_failed")
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
@@ -89,6 +127,157 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
}
return data, nil
}
+
+type digestChallenge struct {
+ realm string
+ nonce string
+ opaque string
+ algorithm string
+ qop string
+}
+
+func parseDigestChallenge(values []string) (digestChallenge, error) {
+ for _, value := range values {
+ if !strings.EqualFold(strings.TrimSpace(strings.SplitN(value, " ", 2)[0]), "Digest") {
+ continue
+ }
+ parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
+ if len(parts) != 2 {
+ break
+ }
+ params, err := parseAuthParameters(parts[1])
+ if err != nil {
+ return digestChallenge{}, err
+ }
+ challenge := digestChallenge{
+ realm: strings.TrimSpace(params["realm"]), nonce: strings.TrimSpace(params["nonce"]),
+ opaque: strings.TrimSpace(params["opaque"]), algorithm: strings.ToUpper(strings.TrimSpace(params["algorithm"])),
+ }
+ if challenge.realm == "" || challenge.nonce == "" {
+ return digestChallenge{}, fmt.Errorf("invalid_digest_challenge")
+ }
+ if challenge.algorithm == "" {
+ challenge.algorithm = "MD5"
+ }
+ if challenge.algorithm != "MD5" && challenge.algorithm != "SHA-256" {
+ return digestChallenge{}, fmt.Errorf("unsupported_digest_algorithm")
+ }
+ qops := strings.Split(params["qop"], ",")
+ for _, qop := range qops {
+ if strings.EqualFold(strings.TrimSpace(qop), "auth") {
+ challenge.qop = "auth"
+ break
+ }
+ }
+ if params["qop"] != "" && challenge.qop == "" {
+ return digestChallenge{}, fmt.Errorf("unsupported_digest_qop")
+ }
+ return challenge, nil
+ }
+ return digestChallenge{}, fmt.Errorf("digest_challenge_not_found")
+}
+
+func parseAuthParameters(value string) (map[string]string, error) {
+ result := map[string]string{}
+ for position := 0; position < len(value); {
+ for position < len(value) && (value[position] == ' ' || value[position] == ',') {
+ position++
+ }
+ start := position
+ for position < len(value) && value[position] != '=' && value[position] != ',' {
+ position++
+ }
+ if position == start || position >= len(value) || value[position] != '=' {
+ return nil, fmt.Errorf("invalid_digest_challenge")
+ }
+ name := strings.ToLower(strings.TrimSpace(value[start:position]))
+ position++
+ var parameter string
+ if position < len(value) && value[position] == '"' {
+ position++
+ var builder strings.Builder
+ closed := false
+ for position < len(value) {
+ if value[position] == '"' {
+ position++
+ closed = true
+ break
+ }
+ if value[position] == '\\' && position+1 < len(value) {
+ position++
+ }
+ builder.WriteByte(value[position])
+ position++
+ }
+ if !closed {
+ return nil, fmt.Errorf("invalid_digest_challenge")
+ }
+ parameter = builder.String()
+ } else {
+ start = position
+ for position < len(value) && value[position] != ',' {
+ position++
+ }
+ parameter = strings.TrimSpace(value[start:position])
+ }
+ result[name] = parameter
+ }
+ return result, nil
+}
+
+func digestAuthorization(method, uri string, credential Credential, challenge digestChallenge) (string, error) {
+ cnonceBytes := make([]byte, 16)
+ if _, err := rand.Read(cnonceBytes); err != nil {
+ return "", fmt.Errorf("generate_digest_cnonce: %w", err)
+ }
+ cnonce := fmt.Sprintf("%x", cnonceBytes)
+ hash := func(value string) string {
+ if challenge.algorithm == "SHA-256" {
+ sum := sha256.Sum256([]byte(value))
+ return fmt.Sprintf("%x", sum)
+ }
+ sum := md5.Sum([]byte(value))
+ return fmt.Sprintf("%x", sum)
+ }
+ ha1 := hash(credential.Username + ":" + challenge.realm + ":" + credential.Password)
+ ha2 := hash(method + ":" + uri)
+ nonceCount := "00000001"
+ response := hash(ha1 + ":" + challenge.nonce + ":" + ha2)
+ if challenge.qop != "" {
+ response = hash(ha1 + ":" + challenge.nonce + ":" + nonceCount + ":" + cnonce + ":" + challenge.qop + ":" + ha2)
+ }
+ values := []string{
+ `username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(challenge.realm),
+ `nonce=` + strconv.Quote(challenge.nonce), `uri=` + strconv.Quote(uri),
+ `response=` + strconv.Quote(response), `algorithm=` + challenge.algorithm,
+ }
+ if challenge.opaque != "" {
+ values = append(values, `opaque=`+strconv.Quote(challenge.opaque))
+ }
+ if challenge.qop != "" {
+ values = append(values, `qop=`+challenge.qop, `nc=`+nonceCount, `cnonce=`+strconv.Quote(cnonce))
+ }
+ return "Digest " + strings.Join(values, ", "), nil
+}
+
+func normalizeServiceEndpoint(deviceEndpoint, advertisedEndpoint string) (string, error) {
+ device, err := url.Parse(deviceEndpoint)
+ if err != nil {
+ return "", fmt.Errorf("invalid ONVIF address")
+ }
+ advertised, err := url.Parse(advertisedEndpoint)
+ if err != nil || advertised.Scheme == "" || advertised.Host == "" || advertised.User != nil {
+ return "", fmt.Errorf("invalid ONVIF media address")
+ }
+ if advertised.Scheme != "http" && advertised.Scheme != "https" {
+ return "", fmt.Errorf("unsupported ONVIF media scheme")
+ }
+ if !strings.EqualFold(advertised.Hostname(), device.Hostname()) {
+ advertised.Scheme = device.Scheme
+ advertised.Host = device.Host
+ }
+ return advertised.String(), nil
+}
func validateEndpoint(value string) (string, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
@@ -107,3 +296,4 @@ func xmlEscape(value string) string {
_ = xml.EscapeText(&b, []byte(value))
return b.String()
}
+
--
2.34.1
From 2591517c76fc3528db2f255ad6ec922b1450c4dc Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:25 +0800
Subject: [PATCH 2/9] test: cover ONVIF Digest compatibility (#48)
---
.../app/sense/adapters/onvif/client_test.go | 144 ++++++++++++++++++
1 file changed, 144 insertions(+)
create mode 100644 Sense/server/app/sense/adapters/onvif/client_test.go
diff --git a/Sense/server/app/sense/adapters/onvif/client_test.go b/Sense/server/app/sense/adapters/onvif/client_test.go
new file mode 100644
index 0000000..c8d6154
--- /dev/null
+++ b/Sense/server/app/sense/adapters/onvif/client_test.go
@@ -0,0 +1,144 @@
+package onvif
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestProfilesDiscoversMediaServiceAndUsesDigest(t *testing.T) {
+ var digestRequests atomic.Int32
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/onvif/device_service":
+ fmt.Fprintf(w, `%s/onvif/media_service`, server.URL)
+ case "/onvif/media_service":
+ authorization := r.Header.Get("Authorization")
+ if !strings.HasPrefix(authorization, "Digest ") {
+ w.Header().Set("WWW-Authenticate", `Digest realm="camera", nonce="nonce-1", algorithm=MD5, qop="auth"`)
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ digestRequests.Add(1)
+ if strings.Contains(readRequestBody(t, r), "GetProfiles") {
+ fmt.Fprint(w, `MainH26419201080`)
+ return
+ }
+ fmt.Fprint(w, `rtsp://camera.invalid/live`)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ profiles, err := NewHTTPClient(2*time.Second).Profiles(context.Background(), server.URL+"/onvif/device_service", Credential{Username: "operator", Password: "secret"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://camera.invalid/live" {
+ t.Fatalf("profiles=%#v", profiles)
+ }
+ if digestRequests.Load() != 2 {
+ t.Fatalf("digest requests=%d", digestRequests.Load())
+ }
+}
+
+func TestNormalizeServiceEndpoint(t *testing.T) {
+ tests := []struct {
+ name string
+ device string
+ advertised string
+ want string
+ wantError bool
+ }{
+ {name: "same host keeps media port", device: "http://camera.local:80/device", advertised: "http://camera.local:8000/media", want: "http://camera.local:8000/media"},
+ {name: "different host uses authorized origin", device: "http://192.0.2.10:8080/device", advertised: "http://unusable.local:9000/media?profile=1", want: "http://192.0.2.10:8080/media?profile=1"},
+ {name: "reject credentials", device: "http://camera.local/device", advertised: "http://user:pass@camera.local/media", wantError: true},
+ {name: "reject scheme", device: "http://camera.local/device", advertised: "ftp://camera.local/media", wantError: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := normalizeServiceEndpoint(test.device, test.advertised)
+ if test.wantError {
+ if err == nil {
+ t.Fatalf("got=%q", got)
+ }
+ return
+ }
+ if err != nil || got != test.want {
+ t.Fatalf("got=%q err=%v", got, err)
+ }
+ })
+ }
+}
+
+func TestRejectUnsupportedDigestChallenge(t *testing.T) {
+ for _, challenge := range []string{
+ `Digest realm="camera", nonce="n", algorithm=SHA-512, qop="auth"`,
+ `Digest realm="camera", nonce="n", algorithm=MD5, qop="auth-int"`,
+ `Digest realm="camera"`,
+ } {
+ if _, err := parseDigestChallenge([]string{challenge}); err == nil {
+ t.Fatalf("challenge accepted: %s", challenge)
+ }
+ }
+}
+
+func TestValidateEndpointRejectsCredentials(t *testing.T) {
+ if _, err := validateEndpoint("http://user:pass@camera.invalid/onvif"); err == nil {
+ t.Fatal("credential endpoint accepted")
+ }
+}
+
+func TestSOAPDoesNotFollowRedirect(t *testing.T) {
+ redirectTargetCalled := false
+ target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ redirectTargetCalled = true
+ }))
+ defer target.Close()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, target.URL, http.StatusFound)
+ }))
+ defer server.Close()
+
+ _, err := NewHTTPClient(time.Second).soap(context.Background(), server.URL, Credential{Username: "operator", Password: "secret"}, "")
+ if err == nil || redirectTargetCalled {
+ t.Fatalf("err=%v redirect_target_called=%v", err, redirectTargetCalled)
+ }
+}
+
+func readRequestBody(t *testing.T, r *http.Request) string {
+ t.Helper()
+ defer r.Body.Close()
+ data, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(data)
+}
+
+func TestDigestAuthorizationUsesRequestURI(t *testing.T) {
+ header, err := digestAuthorization(http.MethodPost, "/media?profile=1", Credential{Username: "operator", Password: "secret"}, digestChallenge{realm: "camera", nonce: "n", algorithm: "SHA-256", qop: "auth"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := parseAuthParameters(strings.TrimPrefix(header, "Digest "))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if parsed["uri"] != "/media?profile=1" || parsed["username"] != "operator" || parsed["response"] == "" {
+ t.Fatalf("invalid digest fields: %#v", parsed)
+ }
+ if _, err := url.Parse(parsed["uri"]); err != nil {
+ t.Fatal(err)
+ }
+}
+
--
2.34.1
From 523fe81fb25007ef19f63cfaf16c486ad21a131c Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:26 +0800
Subject: [PATCH 3/9] fix: parse ONVIF media service address (#48)
---
.../server/app/sense/adapters/onvif/parser.go | 30 +++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/Sense/server/app/sense/adapters/onvif/parser.go b/Sense/server/app/sense/adapters/onvif/parser.go
index 56b2665..f3b1283 100644
--- a/Sense/server/app/sense/adapters/onvif/parser.go
+++ b/Sense/server/app/sense/adapters/onvif/parser.go
@@ -54,3 +54,33 @@ func ParseStreamURI(data []byte) (string, error) {
}
}
}
+
+func ParseMediaServiceAddress(data []byte) (string, error) {
+ decoder := xml.NewDecoder(strings.NewReader(string(data)))
+ mediaDepth := 0
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return "", fmt.Errorf("media_service_not_found")
+ }
+ switch value := token.(type) {
+ case xml.StartElement:
+ if value.Name.Local == "Media" {
+ mediaDepth++
+ continue
+ }
+ if mediaDepth > 0 && value.Name.Local == "XAddr" {
+ var address string
+ if err := decoder.DecodeElement(&address, &value); err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(address), nil
+ }
+ case xml.EndElement:
+ if value.Name.Local == "Media" && mediaDepth > 0 {
+ mediaDepth--
+ }
+ }
+ }
+}
+
--
2.34.1
From 05100c4f3718517e08a717e4b79dcbf897c15bcd Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:28 +0800
Subject: [PATCH 4/9] test: cover ONVIF media address parsing (#48)
---
.../app/sense/adapters/onvif/parser_test.go | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/Sense/server/app/sense/adapters/onvif/parser_test.go b/Sense/server/app/sense/adapters/onvif/parser_test.go
index 1da8a27..230a891 100644
--- a/Sense/server/app/sense/adapters/onvif/parser_test.go
+++ b/Sense/server/app/sense/adapters/onvif/parser_test.go
@@ -29,3 +29,18 @@ func TestRejectCredentialInStreamURI(t *testing.T) {
t.Fatal("credential URI accepted")
}
}
+
+func TestParseMediaServiceAddress(t *testing.T) {
+ data := []byte(`http://camera.invalid:8000/onvif/media_service`)
+ address, err := ParseMediaServiceAddress(data)
+ if err != nil || address != "http://camera.invalid:8000/onvif/media_service" {
+ t.Fatalf("address=%q err=%v", address, err)
+ }
+}
+
+func TestParseMediaServiceAddressRejectsMissingMedia(t *testing.T) {
+ if _, err := ParseMediaServiceAddress([]byte(``)); err == nil {
+ t.Fatal("missing media service accepted")
+ }
+}
+
--
2.34.1
From 850ca4fead9a9932b64a60d14c4bfaa845e46294 Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:30 +0800
Subject: [PATCH 5/9] docs: document ONVIF Digest architecture (#48)
---
docs/02-architecture-and-code-map.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index 60c6f88..566294a 100644
--- a/docs/02-architecture-and-code-map.md
+++ b/docs/02-architecture-and-code-map.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
-wiki_revision: 1712a8053f365781aef9cbf33c576c97491f31d4
-synchronized_at: 2026-08-12T10:21:55Z
+wiki_revision: fbb20f3bcffe5c632236108e7883ac9a0061d830
+synchronized_at: 2026-08-13T03:39:20Z
# 架构与代码地图
@@ -101,7 +101,7 @@ Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server
- `identity/`:Sense 独立账户、bcrypt 密码、会话、四角色 RBAC 与统一审计;签发者和受众只属于 Sense。
- `device/`:Device 台账、状态、分页和 AES-256-GCM 凭据保险箱;读取模型只返回 `credential_configured`。
-- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Profile/StreamUri 读取和 RTSP 验证。
+- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Media 服务发现、Basic/Digest 认证、Profile/StreamUri 读取和 RTSP 验证。摄像机广播跨主机 Media 地址时固定回用户已授权的 Device Service origin,仅保留服务路径和查询参数。
- `adapters/mediamtx/`、`media/`:外部 MediaMTX 进程所有权、localhost Control API、媒体期望态与实际态对账。
- `liveview/`:绑定当前用户、最长两分钟的单路播放会话;只投影媒体路径,不暴露源 URI 或摄像机秘密。
- `area/`:归一化多边形/方向警戒线、不可变版本、并发版本校验和分辨率变化后的重新校准。
@@ -124,3 +124,4 @@ Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server
Event 写入、幂等 Receipt、规则评估和 Alert 创建/关联处于同一数据库事务;规则或关联失败时不保留半成品 Event。一个 Event 可匹配多个规则,一个未关闭 Alert 可聚合相同规则与地点的多个 Event。
+
--
2.34.1
From c8cf53291f158d0860887d061bb59d0dfcc7d850 Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:31 +0800
Subject: [PATCH 6/9] docs: document ONVIF Digest verification (#48)
---
docs/04-local-development-and-verification.md | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md
index e891b71..9abef2a 100644
--- a/docs/04-local-development-and-verification.md
+++ b/docs/04-local-development-and-verification.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
-wiki_revision: 4f3e1f218d29e17a3f8fabd43316d1a562c69d07
-synchronized_at: 2026-08-13T02:35:03Z
+wiki_revision: 426001a8c28bde9c336ecc8b7166924f14aa55ae
+synchronized_at: 2026-08-13T03:39:26Z
# 本地开发与验证
@@ -73,8 +73,8 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
## 测试数据与日志
-- 只使用合成或脱敏事件、合成 RTSP 和明确授权的实验室设备。
-- 不提交真实视频、客户名称、地址、手机号、摄像头密码或通知凭据。
+- 只使用合成或脱敏事件、合成 RTSP 和明确授权的实验室设备。ONVIF 自动化测试需覆盖 Basic、Digest challenge、Media 服务发现、跨主机地址归一化、拒绝地址凭据和禁止重定向。
+- 不提交真实视频、客户名称、地址、手机号、摄像头密码或通知凭据;真实设备验证只记录状态与 Profile 数量,不记录设备地址、Authorization 或 Stream URI。
- 日志必须可按 request/event/alert ID 追踪,但不得记录 Authorization、Cookie 或连接密钥。
## 完成修改前
@@ -171,3 +171,4 @@ Get-FileHash .\Sense\dist\sense-windows-amd64.zip -Algorithm SHA256
解压后运行 `start-sense.bat demo` 可做内存模式临时预览。生产配置可写入运行目录的 `config\sense.env`,也可通过 Windows 进程环境注入;非空进程环境变量优先,启动器只读取 `SENSE_*` 键且不打印配置值。运行 `start-sense.bat check` 可在不连接数据库、不启动服务的情况下检查必填配置,然后用 `start-sense.bat` 启动生产模式。包内不含 PostgreSQL、MediaMTX、系统服务、客户数据或秘密,真实 `sense.env` 不得提交或重新打入交付 ZIP。
+
--
2.34.1
From 5b466b4ae64e19deb1fc89022984fdfa0ddee31e Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:33 +0800
Subject: [PATCH 7/9] docs: document ONVIF credential troubleshooting (#48)
---
docs/06-troubleshooting.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md
index d5f99ad..141bac4 100644
--- a/docs/06-troubleshooting.md
+++ b/docs/06-troubleshooting.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
-wiki_revision: 84d5d336475613d77030a7c722ea9c4d18b307fe
-synchronized_at: 2026-08-13T02:35:08Z
+wiki_revision: 832eace3bb0083168cc9ff11f689180e2d13bdea
+synchronized_at: 2026-08-13T03:39:34Z
# 故障排查
@@ -50,7 +50,7 @@ synchronized_at: 2026-08-13T02:35:08Z
| 管理员登录后侧栏没有模块 | 先确认 /api/v1/identity/me 返回角色与权限;若权限正常,检查前端是否从具有 children 的应用布局路由派生菜单,不得依赖重复 / 路由记录顺序。 |
| `adapter_not_ready` | 当前设备类型尚无适配器,不代表网络故障;首期完整支持 video。 |
| `discovery_unavailable` | 未设置获准的 `SENSE_ONVIF_DISCOVERY_IP`,或该 IP 不属于本机网卡。可改用手工 ONVIF 地址。 |
-| `authentication_failed` | 在设备管理中重新写入凭据后再次执行接入检查;不要把凭据写进地址。 |
+| `authentication_failed` | 在设备管理中重新写入凭据后再次执行接入检查;不要把凭据写进地址。Sense 支持 ONVIF Basic 与 Digest;若 Profile 能读取但视频验证失败,确认 ONVIF 与 RTSP 是否使用同一组账号。当前每台设备只保存一组凭据,不同账号需后续凭据模型支持。 |
| `clock_skew` | 校准摄像机时间后重新探测。 |
| `process_failed` | 检查仓库外 `SENSE_MEDIAMTX_BINARY`、基础配置和进程退出原因;达到三次重启上限后需人工处理。 |
| `apply_failed` / `unconverged` | 检查 localhost Control API 是否启用并为 v3;确认媒体路径和外部进程状态。 |
@@ -72,3 +72,4 @@ synchronized_at: 2026-08-13T02:35:08Z
`demo` 只用于临时查看。生产数据持久性、真实设备和媒体链路不能用 demo 验证替代。
+
--
2.34.1
From ff9359200190f7c51ab31a8bc205df17e69c22cd Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:34 +0800
Subject: [PATCH 8/9] docs: register ONVIF Digest task archive (#48)
---
wiki-docs.json | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/wiki-docs.json b/wiki-docs.json
index 27c97a6..62b2a93 100644
--- a/wiki-docs.json
+++ b/wiki-docs.json
@@ -131,6 +131,11 @@
{
"page": "Task-46-Sense-Windows包内配置加载",
"path": "docs/task/46-Sense-Windows包内配置加载.md"
+ },
+ {
+ "page": "Task-48-支持ONVIF-Digest认证与安全Media地址归一化",
+ "path": "docs/task/48-支持ONVIF-Digest认证与安全Media地址归一化.md"
}
]
}
+
--
2.34.1
From c29ce45971ef2e757a3b03b56761ca75d503ae44 Mon Sep 17 00:00:00 2001
From: ila <2+ila@noreply.git.ilapage.cn>
Date: Thu, 13 Aug 2026 11:52:35 +0800
Subject: [PATCH 9/9] docs: archive ONVIF Digest compatibility (#48)
---
...gest认证与安全Media地址归一化.md | 75 +++++++++++++++++++
1 file changed, 75 insertions(+)
create mode 100644 docs/task/48-支持ONVIF-Digest认证与安全Media地址归一化.md
diff --git a/docs/task/48-支持ONVIF-Digest认证与安全Media地址归一化.md b/docs/task/48-支持ONVIF-Digest认证与安全Media地址归一化.md
new file mode 100644
index 0000000..72b72fa
--- /dev/null
+++ b/docs/task/48-支持ONVIF-Digest认证与安全Media地址归一化.md
@@ -0,0 +1,75 @@
+
+generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
+wiki_page: Task-48-支持ONVIF-Digest认证与安全Media地址归一化
+wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-48-%E6%94%AF%E6%8C%81ONVIF-Digest%E8%AE%A4%E8%AF%81%E4%B8%8E%E5%AE%89%E5%85%A8Media%E5%9C%B0%E5%9D%80%E5%BD%92%E4%B8%80%E5%8C%96.-
+wiki_revision: 16667fcc50dac28098a3c4b5b9018bac1ff249be
+synchronized_at: 2026-08-13T03:46:29Z
+
+
+# 48 支持ONVIF Digest认证与安全Media地址归一化
+
+- 类型:缺陷
+- 所属 Epic:#7
+- 所属 MVP / 版本:#8
+- 状态:待验收
+- 日期:2026-08-13
+- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/48
+- Wiki 页面:Task-48-支持ONVIF-Digest认证与安全Media地址归一化
+- Wiki revision:见本地镜像头
+
+## 背景与目标
+
+真实摄像机的 Device Service 可以访问,但 Media Service 要求 Digest Authentication,并广播了当前主机无法访问的 Media 地址。旧版 Sense 只预发送 Basic,并把 GetProfiles 发往 Device Service,无法读取 Profile。本任务在不泄露设备信息的前提下兼容该设备。
+
+## 最终方案
+
+- 先向用户填写的 Device Service 请求 GetCapabilities,解析 Media XAddr,再向 Media Service请求 GetProfiles 与 GetStreamUri。
+- 收到 Digest challenge 时仅重试一次,支持 MD5、SHA-256 和 qop=auth;拒绝缺失必填参数、不支持的算法和 qop。
+- Media XAddr 与 Device Service 同主机时保留服务公布的端口;跨主机时固定回用户已授权的 Device Service scheme/host/port,仅保留 Media path/query。
+- HTTP 客户端不跟随重定向,继续拒绝地址或 Stream URI 中携带凭据,不记录 Authorization。
+- 保留 Basic 兼容路径。真实设备使用专用 ONVIF 凭据成功读取 2 个 Profile。
+- 当前真实设备 ONVIF 与 RTSP 使用不同账号,而 Sense 每台设备只保存一组凭据,因此 RTSP 验证仍为 profile_failed;不同凭据模型不在本工单范围。
+
+## 修改文件
+
+- `Sense/server/app/sense/adapters/onvif/client.go`:Media 服务发现、Digest challenge-response、地址归一化和重定向禁止。
+- `Sense/server/app/sense/adapters/onvif/client_test.go`:Digest、Basic 流程、安全地址和重定向测试。
+- `Sense/server/app/sense/adapters/onvif/parser.go`:解析 GetCapabilities 中的 Media XAddr。
+- `Sense/server/app/sense/adapters/onvif/parser_test.go`:Media 地址解析测试。
+- `docs/02-architecture-and-code-map.md`、`docs/04-local-development-and-verification.md`、`docs/06-troubleshooting.md`:由 Wiki 同步的长期说明。
+
+## 验收结果
+
+| 验收标准 | 结果 |
+|---|---|
+| Digest Media Service 可完成 GetProfiles 和 GetStreamUri | 通过 |
+| 用户只填写 Device Service,Sense 自动发现 Media Service | 通过 |
+| 跨主机 Media XAddr 不被直接访问 | 通过 |
+| 同源合法 Media XAddr 与 Basic 路径不回归 | 通过 |
+| 危险地址、错误 challenge、算法/qop、重定向被拒绝 | 通过 |
+| 真实设备能够读取 Profile | 通过,读取 2 个 Profile |
+| 不泄露设备地址、凭据、Authorization 或 Stream URI | 通过 |
+
+## 测试
+
+- 执行命令:`go test ./app/sense/adapters/onvif ./app/sense/admission`
+- 结果:通过。
+- 执行命令:`go test ./...`
+- 结果:Sense 全部 Go 测试通过。
+- 执行命令:`Sense/scripts/package-windows.bat`(Go 1.26.5、Node 22.22.1、pnpm 9.15.1)
+- 结果:前后端 Windows 包构建通过;只有既有 webpack 体积警告。
+- 真实设备验证:通过 Sense API 使用只读 `ip_camera.env`,不输出敏感值。
+- 结果:`profile_count=2`、`admission_status=profile_failed`;Profile 已读取,RTSP 因 ONVIF/RTSP 不同账号未通过。
+- Windows ZIP SHA-256:`628CC85B009628C062ADB6F5125A2F89CF7501727042152D29D13A7AE6D3F888`。
+- **未验证部分**:未完成真实 RTSP 播放与 MediaMTX 接入;需要每设备分离 ONVIF/RTSP 凭据后再验收。
+
+## 遗留问题
+
+- 真实摄像机的 ONVIF 与 RTSP 使用不同账号;Sense 当前单凭据模型无法同时验证两者,需要独立工单扩展凭据边界。
+- 严格 Harness 当前仍被既有工单 #44 归档缺少“最终方案”章节阻塞,不在本工单中混入修复。
+
+## 相关提交
+
+- `688080c` 支持 ONVIF Digest Media 服务与安全地址归一化。
+- `2a0b63c` 更新架构、验证与排错 Wiki 镜像。
+
--
2.34.1