From 2bb1614a3cc0ca24e209d60ceecddeea72e2e86c Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Fri, 14 Aug 2026 16:50:00 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=BB=BA=20ONVIF=20=E4=B8=8E?=
=?UTF-8?q?=20RTSP=20=E8=A7=86=E9=A2=91=E6=8E=A5=E5=85=A5=20(#66)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Sense/README.md | 2 +
.../app/admin/router/sense_admission.go | 18 ++
Sense/server/app/sense/admission/apis.go | 105 +++++++
Sense/server/app/sense/admission/apis_test.go | 21 ++
Sense/server/app/sense/admission/dto.go | 34 ++
Sense/server/app/sense/admission/models.go | 32 ++
Sense/server/app/sense/admission/service.go | 171 ++++++++++
.../app/sense/admission/service_test.go | 107 +++++++
Sense/server/app/sense/credential/reader.go | 35 +++
.../server/app/sense/device/models/device.go | 2 +
Sense/server/app/sense/onvif/client.go | 296 ++++++++++++++++++
Sense/server/app/sense/onvif/client_test.go | 81 +++++
Sense/server/app/sense/onvif/discovery.go | 96 ++++++
Sense/server/app/sense/onvif/parser.go | 75 +++++
Sense/server/app/sense/onvif/policy.go | 76 +++++
Sense/server/app/sense/rtsp/verifier.go | 70 +++++
Sense/server/app/sense/rtsp/verifier_test.go | 55 ++++
.../version/2026081417000_profile.go | 60 ++++
Sense/server/config/credential.env.example | 5 +
Sense/server/tests/admission/postgres_test.go | 85 +++++
Sense/ui/src/api/sense/admission.js | 5 +
.../views/sense/admission/admissionPayload.js | 7 +
Sense/ui/src/views/sense/admission/index.vue | 80 +++++
Sense/ui/src/views/sense/device/index.vue | 3 +-
.../tests/unit/sense/admissionPayload.spec.js | 11 +
25 files changed, 1531 insertions(+), 1 deletion(-)
create mode 100644 Sense/server/app/admin/router/sense_admission.go
create mode 100644 Sense/server/app/sense/admission/apis.go
create mode 100644 Sense/server/app/sense/admission/apis_test.go
create mode 100644 Sense/server/app/sense/admission/dto.go
create mode 100644 Sense/server/app/sense/admission/models.go
create mode 100644 Sense/server/app/sense/admission/service.go
create mode 100644 Sense/server/app/sense/admission/service_test.go
create mode 100644 Sense/server/app/sense/credential/reader.go
create mode 100644 Sense/server/app/sense/onvif/client.go
create mode 100644 Sense/server/app/sense/onvif/client_test.go
create mode 100644 Sense/server/app/sense/onvif/discovery.go
create mode 100644 Sense/server/app/sense/onvif/parser.go
create mode 100644 Sense/server/app/sense/onvif/policy.go
create mode 100644 Sense/server/app/sense/rtsp/verifier.go
create mode 100644 Sense/server/app/sense/rtsp/verifier_test.go
create mode 100644 Sense/server/cmd/migrate/migration/version/2026081417000_profile.go
create mode 100644 Sense/server/tests/admission/postgres_test.go
create mode 100644 Sense/ui/src/api/sense/admission.js
create mode 100644 Sense/ui/src/views/sense/admission/admissionPayload.js
create mode 100644 Sense/ui/src/views/sense/admission/index.vue
create mode 100644 Sense/ui/tests/unit/sense/admissionPayload.spec.js
diff --git a/Sense/README.md b/Sense/README.md
index afd911b..445443a 100644
--- a/Sense/README.md
+++ b/Sense/README.md
@@ -42,4 +42,6 @@ go run . server -c C:\secure-path\sense-settings.yml
设备台账本身可以在不配置摄像头凭据的情况下使用。创建或更新 ONVIF/RTSP 凭据前,还必须在启动进程环境中设置 `SENSE_CREDENTIAL_KEY`:该值是随机 32 字节密钥的 Base64 编码,仅保存在仓库外。变量名模板见 `server/config/credential.env.example`;不要把真实值写入配置、脚本、日志或工单。密钥缺失或格式不正确时,Sense 会拒绝凭据写入,不会降级为明文存储。
+使用 ONVIF 发现或手工接入前,还必须设置 `SENSE_ONVIF_DISCOVERY_IP` 和 `SENSE_ONVIF_ALLOWED_CIDRS`。前者只能是获准用于 WS-Discovery 的本机网卡地址;后者是获准访问的摄像头网段(多个 CIDR 用逗号分隔)。未配置时系统会给出可行动提示且不会扫描任意网卡;手工地址、Media XAddr 和 Stream URI 同样受该网段限制,并拒绝重定向或 URL 内凭据。
+
仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
diff --git a/Sense/server/app/admin/router/sense_admission.go b/Sense/server/app/admin/router/sense_admission.go
new file mode 100644
index 0000000..384dde4
--- /dev/null
+++ b/Sense/server/app/admin/router/sense_admission.go
@@ -0,0 +1,18 @@
+package router
+
+import (
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
+ "git.ilapage.cn/ila/yovision/Sense/server/common/actions"
+ "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
+ "github.com/gin-gonic/gin"
+ jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
+)
+
+func init() { routerCheckRole = append(routerCheckRole, registerSenseAdmissionRouter) }
+func registerSenseAdmissionRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
+ api := &admission.API{}
+ r := v1.Group("/admission").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
+ r.GET("/discover", api.Discover)
+ r.GET("/devices/:id", api.Get)
+ r.POST("/devices/:id/probe", api.Probe)
+}
diff --git a/Sense/server/app/sense/admission/apis.go b/Sense/server/app/sense/admission/apis.go
new file mode 100644
index 0000000..52cd72c
--- /dev/null
+++ b/Sense/server/app/sense/admission/apis.go
@@ -0,0 +1,105 @@
+package admission
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/go-admin-team/go-admin-core/sdk/api"
+ "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
+)
+
+type API struct{ api.Api }
+
+func (e *API) runtime(c *gin.Context) (*Service, error) {
+ base := Service{}
+ if err := e.MakeContext(c).MakeOrm().MakeService(&base.Service).Errors; err != nil {
+ return nil, err
+ }
+ return NewRuntime(base.Service)
+}
+func (e *API) Discover(c *gin.Context) {
+ service, err := e.runtime(c)
+ if err != nil {
+ e.writeError(err)
+ return
+ }
+ result, err := service.Discover(c.Request.Context())
+ if err != nil {
+ e.writeError(err)
+ return
+ }
+ e.OK(result, "发现完成")
+}
+func (e *API) Probe(c *gin.Context) {
+ service, err := e.runtime(c)
+ if err != nil {
+ e.writeError(err)
+ return
+ }
+ request := ProbeRequest{DeviceID: c.Param("id"), UpdateBy: user.GetUserId(c)}
+ if err = bindStrict(c, &request); err != nil {
+ e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
+ return
+ }
+ result, err := service.Probe(c.Request.Context(), request)
+ if err != nil {
+ e.writeError(err)
+ return
+ }
+ e.OK(result, "探测完成")
+}
+func (e *API) Get(c *gin.Context) {
+ service := &Service{}
+ if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
+ e.writeError(err)
+ return
+ }
+ result, err := service.Get(c.Param("id"))
+ if err != nil {
+ e.writeError(err)
+ return
+ }
+ e.OK(result, "查询成功")
+}
+func (e *API) writeError(err error) {
+ switch {
+ case errors.Is(err, ErrInvalid):
+ e.Error(http.StatusBadRequest, err, err.Error())
+ case errors.Is(err, ErrNotFound):
+ e.Error(http.StatusNotFound, err, err.Error())
+ case errors.Is(err, ErrConflict):
+ e.Error(http.StatusConflict, err, err.Error())
+ case errors.Is(err, onvif.ErrDiscoveryNotConfigured), errors.Is(err, onvif.ErrDiscoveryInterface), errors.Is(err, onvif.ErrTargetNotAllowed):
+ e.Error(http.StatusBadRequest, err, err.Error())
+ case errors.Is(err, credential.ErrCredentialNotConfigured):
+ e.Error(http.StatusConflict, err, "请先在设备管理中配置摄像头凭据")
+ case errors.Is(err, credential.ErrKeyUnavailable):
+ e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
+ default:
+ e.Error(http.StatusInternalServerError, err, "视频接入操作失败")
+ }
+}
+func bindStrict(c *gin.Context, target any) error {
+ if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
+ return errors.New("content type must be application/json")
+ }
+ decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return err
+ }
+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
+ if err == nil {
+ return errors.New("request body must contain one JSON object")
+ }
+ return err
+ }
+ return nil
+}
diff --git a/Sense/server/app/sense/admission/apis_test.go b/Sense/server/app/sense/admission/apis_test.go
new file mode 100644
index 0000000..eec55d8
--- /dev/null
+++ b/Sense/server/app/sense/admission/apis_test.go
@@ -0,0 +1,21 @@
+package admission
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestProbePayloadRejectsCredentialFields(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest("POST", "/api/v1/admission/devices/device/probe", strings.NewReader(`{"address":"http://192.0.2.10/onvif","version":1,"password":"must-not-be-accepted"}`))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+ var request ProbeRequest
+ if err := bindStrict(ctx, &request); err == nil {
+ t.Fatal("credential-like unknown field accepted")
+ }
+}
diff --git a/Sense/server/app/sense/admission/dto.go b/Sense/server/app/sense/admission/dto.go
new file mode 100644
index 0000000..e45b380
--- /dev/null
+++ b/Sense/server/app/sense/admission/dto.go
@@ -0,0 +1,34 @@
+package admission
+
+import "time"
+
+type ProbeRequest struct {
+ DeviceID string `json:"-"`
+ Address string `json:"address"`
+ Version int64 `json:"version"`
+ UpdateBy int `json:"-"`
+}
+type ProfileResponse struct {
+ Token string `json:"token"`
+ Name string `json:"name"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Encoding string `json:"encoding"`
+ StreamURI string `json:"streamUri"`
+ Kind string `json:"kind"`
+ VerificationStatus string `json:"verificationStatus"`
+ VerificationLatencyMS int64 `json:"verificationLatencyMs"`
+ VerificationDetail string `json:"verificationDetail"`
+}
+type ResultResponse struct {
+ DeviceID string `json:"deviceId"`
+ Address string `json:"address"`
+ Status string `json:"status"`
+ Detail string `json:"detail"`
+ CheckedAt time.Time `json:"checkedAt"`
+ Profiles []ProfileResponse `json:"profiles"`
+}
+type DiscoveryResponse struct {
+ Addresses []string `json:"addresses"`
+ Interface string `json:"interface"`
+}
diff --git a/Sense/server/app/sense/admission/models.go b/Sense/server/app/sense/admission/models.go
new file mode 100644
index 0000000..9676ddd
--- /dev/null
+++ b/Sense/server/app/sense/admission/models.go
@@ -0,0 +1,32 @@
+package admission
+
+import "time"
+
+type Result struct {
+ DeviceID string `gorm:"size:36;primaryKey"`
+ Address string `gorm:"size:1024;not null"`
+ Status string `gorm:"size:32;not null;index"`
+ Detail string `gorm:"size:512;not null"`
+ CheckedAt time.Time `gorm:"not null"`
+ UpdatedAt time.Time
+ Profiles []Profile `gorm:"foreignKey:DeviceID;references:DeviceID;constraint:OnDelete:CASCADE"`
+}
+
+func (Result) TableName() string { return "sense_admission_results" }
+
+type Profile struct {
+ DeviceID string `gorm:"size:36;primaryKey"`
+ Token string `gorm:"size:255;primaryKey"`
+ Name string `gorm:"size:255;not null"`
+ Width int `gorm:"not null"`
+ Height int `gorm:"not null"`
+ Encoding string `gorm:"size:32;not null"`
+ StreamURI string `gorm:"size:2048;not null"`
+ Kind string `gorm:"size:16;not null"`
+ VerificationStatus string `gorm:"size:32;not null"`
+ VerificationLatencyMS int64 `gorm:"not null"`
+ VerificationDetail string `gorm:"size:512;not null"`
+ UpdatedAt time.Time
+}
+
+func (Profile) TableName() string { return "sense_admission_profiles" }
diff --git a/Sense/server/app/sense/admission/service.go b/Sense/server/app/sense/admission/service.go
new file mode 100644
index 0000000..3899c3c
--- /dev/null
+++ b/Sense/server/app/sense/admission/service.go
@@ -0,0 +1,171 @@
+package admission
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "time"
+
+ coreService "github.com/go-admin-team/go-admin-core/sdk/service"
+ "gorm.io/gorm"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
+ deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
+)
+
+var (
+ ErrNotFound = errors.New("尚无该设备的接入结果")
+ ErrInvalid = errors.New("接入请求不符合要求")
+ ErrConflict = errors.New("设备已被其他用户更新,请刷新后重试")
+)
+
+type Service struct {
+ coreService.Service
+ ONVIF onvif.Client
+ RTSP rtsp.Verifier
+ Policy onvif.Policy
+ DiscoveryIP string
+ Timeout time.Duration
+}
+
+func NewRuntime(service coreService.Service) (*Service, error) {
+ policy, err := onvif.ParsePolicy(os.Getenv("SENSE_ONVIF_ALLOWED_CIDRS"))
+ if err != nil {
+ return nil, err
+ }
+ return &Service{Service: service, Policy: policy, DiscoveryIP: strings.TrimSpace(os.Getenv("SENSE_ONVIF_DISCOVERY_IP")), ONVIF: onvif.NewHTTPClient(8*time.Second, policy), RTSP: rtsp.NetVerifier{Timeout: 5 * time.Second, Policy: policy}}, nil
+}
+func (s *Service) Discover(ctx context.Context) (DiscoveryResponse, error) {
+ addresses, err := onvif.Discover(ctx, s.DiscoveryIP, 3*time.Second, s.Policy)
+ return DiscoveryResponse{Addresses: addresses, Interface: s.DiscoveryIP}, err
+}
+func (s *Service) Probe(ctx context.Context, request ProbeRequest) (ResultResponse, error) {
+ if request.Version < 1 || strings.TrimSpace(request.Address) == "" || len(request.Address) > 1024 {
+ return ResultResponse{}, ErrInvalid
+ }
+ var device deviceModels.Device
+ if err := s.Orm.Select("id", "modality", "version").First(&device, "id = ?", request.DeviceID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ResultResponse{}, ErrNotFound
+ }
+ return ResultResponse{}, err
+ }
+ if device.Modality != deviceModels.ModalityVideo {
+ return ResultResponse{}, ErrInvalid
+ }
+ if device.Version != request.Version {
+ return ResultResponse{}, ErrConflict
+ }
+ onvifValue, err := credential.Read(s.Orm, request.DeviceID, credential.PurposeONVIF)
+ if err != nil {
+ return ResultResponse{}, err
+ }
+ rtspValue, err := credential.Read(s.Orm, request.DeviceID, credential.PurposeRTSP)
+ if err != nil {
+ return ResultResponse{}, err
+ }
+ profiles, probeErr := s.ONVIF.Profiles(ctx, request.Address, onvif.Credential{Username: onvifValue.Username, Password: onvifValue.Password})
+ now := time.Now().UTC()
+ result := Result{DeviceID: request.DeviceID, Address: strings.TrimSpace(request.Address), CheckedAt: now, UpdatedAt: now}
+ if probeErr != nil {
+ result.Status, result.Detail = classify(probeErr)
+ if err = s.save(result, request, false); err != nil {
+ return ResultResponse{}, err
+ }
+ return s.Get(request.DeviceID)
+ }
+ for _, profile := range profiles {
+ verification, verifyErr := s.RTSP.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: rtspValue.Username, Password: rtspValue.Password})
+ if verifyErr != nil {
+ verification = rtsp.Result{Status: "failed", Detail: "视频地址未通过安全检查"}
+ }
+ result.Profiles = append(result.Profiles, Profile{DeviceID: request.DeviceID, Token: profile.Token, Name: profile.Name, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding, StreamURI: profile.StreamURI, Kind: "other", VerificationStatus: verification.Status, VerificationLatencyMS: verification.LatencyMS, VerificationDetail: verification.Detail, UpdatedAt: now})
+ }
+ sort.Slice(result.Profiles, func(i, j int) bool {
+ return result.Profiles[i].Width*result.Profiles[i].Height > result.Profiles[j].Width*result.Profiles[j].Height
+ })
+ if len(result.Profiles) > 0 {
+ result.Profiles[0].Kind = "main"
+ }
+ if len(result.Profiles) > 1 {
+ result.Profiles[len(result.Profiles)-1].Kind = "sub"
+ }
+ result.Status = "ready"
+ result.Detail = "设备与视频 Profile 已验证"
+ for _, profile := range result.Profiles {
+ if profile.VerificationStatus != "ready" {
+ result.Status = "profile_failed"
+ result.Detail = "部分视频 Profile 验证失败"
+ }
+ }
+ if err = s.save(result, request, true); err != nil {
+ return ResultResponse{}, err
+ }
+ return response(result), nil
+}
+func (s *Service) save(result Result, request ProbeRequest, replaceProfiles bool) error {
+ return s.Orm.Transaction(func(tx *gorm.DB) error {
+ updates := map[string]any{"version": request.Version + 1, "update_by": request.UpdateBy, "updated_at": result.UpdatedAt, "retry_requested_at": nil}
+ if replaceProfiles {
+ updates["status"] = map[bool]string{true: deviceModels.StatusActive, false: deviceModels.StatusPending}[result.Status == "ready"]
+ updates["adapter_status"] = map[bool]string{true: deviceModels.AdapterReady, false: deviceModels.AdapterFailed}[result.Status == "ready"]
+ }
+ update := tx.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", request.DeviceID, request.Version).Updates(updates)
+ if update.Error != nil {
+ return update.Error
+ }
+ if update.RowsAffected == 0 {
+ return ErrConflict
+ }
+ if err := tx.Omit("Profiles").Save(&result).Error; err != nil {
+ return err
+ }
+ if replaceProfiles {
+ if err := tx.Where("device_id = ?", request.DeviceID).Delete(&Profile{}).Error; err != nil {
+ return err
+ }
+ }
+ if replaceProfiles && len(result.Profiles) > 0 {
+ return tx.Create(&result.Profiles).Error
+ }
+ return nil
+ })
+}
+func (s *Service) Get(deviceID string) (ResultResponse, error) {
+ var result Result
+ if err := s.Orm.Preload("Profiles", func(db *gorm.DB) *gorm.DB { return db.Order("width * height DESC") }).First(&result, "device_id = ?", deviceID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ResultResponse{}, ErrNotFound
+ }
+ return ResultResponse{}, fmt.Errorf("read admission: %w", err)
+ }
+ return response(result), nil
+}
+func response(result Result) ResultResponse {
+ out := ResultResponse{DeviceID: result.DeviceID, Address: result.Address, Status: result.Status, Detail: result.Detail, CheckedAt: result.CheckedAt, Profiles: make([]ProfileResponse, 0, len(result.Profiles))}
+ for _, p := range result.Profiles {
+ out.Profiles = append(out.Profiles, ProfileResponse{Token: p.Token, Name: p.Name, Width: p.Width, Height: p.Height, Encoding: p.Encoding, StreamURI: p.StreamURI, Kind: p.Kind, VerificationStatus: p.VerificationStatus, VerificationLatencyMS: p.VerificationLatencyMS, VerificationDetail: p.VerificationDetail})
+ }
+ return out
+}
+func classify(err error) (string, string) {
+ switch {
+ case errors.Is(err, onvif.ErrAuthentication):
+ return "authentication_failed", "设备拒绝了当前凭据,请更新后重试"
+ case errors.Is(err, onvif.ErrTargetNotAllowed):
+ return "target_not_allowed", "设备地址不在获准网段内"
+ case errors.Is(err, onvif.ErrRedirect):
+ return "redirect_rejected", "设备返回了不允许的重定向"
+ case errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout"):
+ return "timeout", "设备响应超时"
+ case strings.Contains(strings.ToLower(err.Error()), "time") || strings.Contains(strings.ToLower(err.Error()), "clock"):
+ return "clock_skew", "设备时间可能不准确,请校时后重试"
+ default:
+ return "unreachable", "无法读取设备信息,请检查地址和网络"
+ }
+}
diff --git a/Sense/server/app/sense/admission/service_test.go b/Sense/server/app/sense/admission/service_test.go
new file mode 100644
index 0000000..1566ef3
--- /dev/null
+++ b/Sense/server/app/sense/admission/service_test.go
@@ -0,0 +1,107 @@
+package admission
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "testing"
+
+ coreService "github.com/go-admin-team/go-admin-core/sdk/service"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
+ deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
+)
+
+type fakeONVIF struct{ err error }
+
+func (f fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
+ if f.err != nil {
+ return nil, f.err
+ }
+ return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/sub"}}, nil
+}
+
+type fakeRTSP struct{}
+
+func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
+ return rtsp.Result{Status: "ready", Detail: "码流可访问"}, nil
+}
+func admissionService(t *testing.T) *Service {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = db.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &Result{}, &Profile{}); err != nil {
+ t.Fatal(err)
+ }
+ key := []byte("0123456789abcdef0123456789abcdef")
+ t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
+ vault, _ := credential.NewVault(key)
+ device := deviceModels.Device{ID: "device-1", Name: "东门摄像机", Modality: deviceModels.ModalityVideo, Version: 1, Status: "pending", AdapterStatus: "ready"}
+ if err = db.Create(&device).Error; err != nil {
+ t.Fatal(err)
+ }
+ for _, purpose := range []string{credential.PurposeONVIF, credential.PurposeRTSP} {
+ cipher, _ := vault.Encrypt(device.ID, purpose, "synthetic-user", "synthetic-password")
+ if err = db.Create(&credential.DeviceCredential{DeviceID: device.ID, Purpose: purpose, Ciphertext: cipher, KeyVersion: credential.Version()}).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ return &Service{Service: coreService.Service{Orm: db}, ONVIF: fakeONVIF{}, RTSP: fakeRTSP{}}
+}
+func TestProbePersistsProfilesWithoutReturningCredentials(t *testing.T) {
+ service := admissionService(t)
+ result, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Status != "ready" || len(result.Profiles) != 2 || result.Profiles[0].Kind != "main" || result.Profiles[1].Kind != "sub" {
+ t.Fatalf("result=%#v", result)
+ }
+ if result.Profiles[0].StreamURI == "" {
+ t.Fatal("stream URI missing")
+ }
+ saved, err := service.Get("device-1")
+ if err != nil || len(saved.Profiles) != 2 {
+ t.Fatalf("saved=%#v err=%v", saved, err)
+ }
+}
+func TestFailedReprobePreservesLastVerifiedProfiles(t *testing.T) {
+ service := admissionService(t)
+ if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
+ t.Fatal(err)
+ }
+ service.ONVIF = fakeONVIF{err: onvif.ErrAuthentication}
+ result, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Status != "authentication_failed" || len(result.Profiles) != 2 {
+ t.Fatalf("last verified profiles lost: %#v", result)
+ }
+ if _, err = service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale error=%v", err)
+ }
+}
+
+func TestProbeErrorsHaveActionableStates(t *testing.T) {
+ for _, test := range []struct {
+ err error
+ status string
+ }{
+ {onvif.ErrAuthentication, "authentication_failed"},
+ {onvif.ErrTargetNotAllowed, "target_not_allowed"},
+ {errors.New("device clock time fault"), "clock_skew"},
+ {context.DeadlineExceeded, "timeout"},
+ } {
+ status, detail := classify(test.err)
+ if status != test.status || detail == "" {
+ t.Fatalf("error=%v status=%s detail=%s", test.err, status, detail)
+ }
+ }
+}
diff --git a/Sense/server/app/sense/credential/reader.go b/Sense/server/app/sense/credential/reader.go
new file mode 100644
index 0000000..6a10eeb
--- /dev/null
+++ b/Sense/server/app/sense/credential/reader.go
@@ -0,0 +1,35 @@
+package credential
+
+import (
+ "errors"
+ "fmt"
+
+ "gorm.io/gorm"
+)
+
+var ErrCredentialNotConfigured = errors.New("摄像头凭据尚未配置")
+
+type Value struct {
+ Username string
+ Password string
+}
+
+// Read is an internal adapter port. HTTP handlers must never expose Value.
+func Read(db *gorm.DB, deviceID, purpose string) (Value, error) {
+ var row DeviceCredential
+ if err := db.First(&row, "device_id = ? AND purpose = ?", deviceID, purpose).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Value{}, ErrCredentialNotConfigured
+ }
+ return Value{}, fmt.Errorf("read device credential: %w", err)
+ }
+ vault, err := NewVaultFromEnvironment()
+ if err != nil {
+ return Value{}, err
+ }
+ username, password, err := vault.Decrypt(deviceID, purpose, row.Ciphertext)
+ if err != nil {
+ return Value{}, err
+ }
+ return Value{Username: username, Password: password}, nil
+}
diff --git a/Sense/server/app/sense/device/models/device.go b/Sense/server/app/sense/device/models/device.go
index e9d52aa..2fefa10 100644
--- a/Sense/server/app/sense/device/models/device.go
+++ b/Sense/server/app/sense/device/models/device.go
@@ -9,8 +9,10 @@ import (
const (
ModalityVideo = "video"
StatusPending = "pending"
+ StatusActive = "active"
StatusDisabled = "disabled"
AdapterReady = "ready"
+ AdapterFailed = "verification_failed"
AdapterNotReady = "adapter_not_ready"
)
diff --git a/Sense/server/app/sense/onvif/client.go b/Sense/server/app/sense/onvif/client.go
new file mode 100644
index 0000000..0dd85ee
--- /dev/null
+++ b/Sense/server/app/sense/onvif/client.go
@@ -0,0 +1,296 @@
+package onvif
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var (
+ ErrAuthentication = errors.New("设备拒绝了当前凭据")
+ ErrRedirect = errors.New("设备返回了不允许的重定向")
+)
+
+type Credential struct{ Username, Password string }
+type Profile struct {
+ Token string `json:"token"`
+ Name string `json:"name"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Encoding string `json:"encoding"`
+ StreamURI string `json:"streamUri"`
+}
+type Client interface {
+ Profiles(context.Context, string, Credential) ([]Profile, error)
+}
+type HTTPClient struct {
+ client *http.Client
+ policy Policy
+}
+
+func NewHTTPClient(timeout time.Duration, policy Policy) *HTTPClient {
+ if timeout <= 0 {
+ timeout = 8 * time.Second
+ }
+ dialer := net.Dialer{Timeout: timeout}
+ transport := &http.Transport{Proxy: nil, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, ErrAddressInvalid
+ }
+ _, ip, err := policy.ValidateURL(ctx, "http://"+net.JoinHostPort(host, port), "http")
+ if err != nil {
+ return nil, err
+ }
+ return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
+ }}
+ return &HTTPClient{policy: policy, client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
+}
+
+func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
+ device, _, err := c.policy.ValidateURL(ctx, address, "http", "https")
+ if err != nil {
+ return nil, err
+ }
+ capabilities, err := c.soap(ctx, device.String(), credential, `All`)
+ if err != nil {
+ return nil, err
+ }
+ mediaRaw, err := parseElement(capabilities, "Media", "XAddr")
+ if err != nil {
+ return nil, err
+ }
+ media, err := c.normalizeService(ctx, device, mediaRaw)
+ if err != nil {
+ return nil, err
+ }
+ data, err := c.soap(ctx, media.String(), credential, ``)
+ if err != nil {
+ return nil, err
+ }
+ profiles, err := parseProfiles(data)
+ if err != nil {
+ return nil, err
+ }
+ for index := range profiles {
+ body := fmt.Sprintf(`RTP-UnicastRTSP%s`, xmlEscape(profiles[index].Token))
+ response, requestErr := c.soap(ctx, media.String(), credential, body)
+ if requestErr != nil {
+ return nil, requestErr
+ }
+ raw, parseErr := parseElement(response, "", "Uri")
+ if parseErr != nil {
+ return nil, parseErr
+ }
+ profiles[index].StreamURI, err = c.normalizeStream(ctx, device, raw)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return profiles, nil
+}
+
+func (c *HTTPClient) normalizeService(ctx context.Context, device *url.URL, raw string) (*url.URL, error) {
+ advertised, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || advertised.Hostname() == "" || advertised.User != nil || (advertised.Scheme != "http" && advertised.Scheme != "https") {
+ return nil, ErrAddressInvalid
+ }
+ if !strings.EqualFold(advertised.Hostname(), device.Hostname()) {
+ advertised.Scheme = device.Scheme
+ advertised.Host = device.Host
+ }
+ if _, _, err = c.policy.ValidateURL(ctx, advertised.String(), "http", "https"); err != nil {
+ return nil, err
+ }
+ return advertised, nil
+}
+func (c *HTTPClient) normalizeStream(ctx context.Context, device *url.URL, raw string) (string, error) {
+ stream, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || len(raw) > 2048 || stream.Hostname() == "" || stream.User != nil || stream.Scheme != "rtsp" {
+ return "", ErrAddressInvalid
+ }
+ if !strings.EqualFold(stream.Hostname(), device.Hostname()) {
+ port := stream.Port()
+ stream.Host = device.Hostname()
+ if port != "" {
+ stream.Host = net.JoinHostPort(device.Hostname(), port)
+ }
+ }
+ if _, _, err = c.policy.ValidateURL(ctx, stream.String(), "rtsp"); err != nil {
+ return "", err
+ }
+ return stream.String(), 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 authorization != "" {
+ req.Header.Set("Authorization", authorization)
+ } else if credential.Username != "" {
+ req.SetBasicAuth(credential.Username, credential.Password)
+ }
+ res, err := c.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("onvif request: %w", err)
+ }
+ defer res.Body.Close()
+ data, err := io.ReadAll(io.LimitReader(res.Body, 2<<20))
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode >= 300 && res.StatusCode < 400 {
+ return nil, ErrRedirect
+ }
+ 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, ErrAuthentication
+ }
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ return nil, fmt.Errorf("onvif http status %d", res.StatusCode)
+ }
+ return data, nil
+}
+
+type digestChallenge struct{ realm, nonce, opaque, algorithm, qop string }
+
+func parseDigestChallenge(values []string) (digestChallenge, error) {
+ for _, value := range values {
+ parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
+ if len(parts) != 2 || !strings.EqualFold(parts[0], "Digest") {
+ continue
+ }
+ params, err := parseAuthParameters(parts[1])
+ if err != nil {
+ return digestChallenge{}, err
+ }
+ c := digestChallenge{realm: params["realm"], nonce: params["nonce"], opaque: params["opaque"], algorithm: strings.ToUpper(params["algorithm"])}
+ if c.realm == "" || c.nonce == "" {
+ return digestChallenge{}, ErrAuthentication
+ }
+ if c.algorithm == "" {
+ c.algorithm = "MD5"
+ }
+ if c.algorithm != "MD5" && c.algorithm != "SHA-256" {
+ return digestChallenge{}, ErrAuthentication
+ }
+ for _, q := range strings.Split(params["qop"], ",") {
+ if strings.EqualFold(strings.TrimSpace(q), "auth") {
+ c.qop = "auth"
+ }
+ }
+ if params["qop"] != "" && c.qop == "" {
+ return digestChallenge{}, ErrAuthentication
+ }
+ return c, nil
+ }
+ return digestChallenge{}, ErrAuthentication
+}
+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, ErrAuthentication
+ }
+ 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, ErrAuthentication
+ }
+ 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, c digestChallenge) (string, error) {
+ random := make([]byte, 16)
+ if _, err := rand.Read(random); err != nil {
+ return "", err
+ }
+ cnonce := fmt.Sprintf("%x", random)
+ hash := func(value string) string {
+ if c.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 + ":" + c.realm + ":" + credential.Password)
+ ha2 := hash(method + ":" + uri)
+ nc := "00000001"
+ response := hash(ha1 + ":" + c.nonce + ":" + ha2)
+ if c.qop != "" {
+ response = hash(ha1 + ":" + c.nonce + ":" + nc + ":" + cnonce + ":" + c.qop + ":" + ha2)
+ }
+ values := []string{`username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(c.realm), `nonce=` + strconv.Quote(c.nonce), `uri=` + strconv.Quote(uri), `response=` + strconv.Quote(response), `algorithm=` + c.algorithm}
+ if c.opaque != "" {
+ values = append(values, `opaque=`+strconv.Quote(c.opaque))
+ }
+ if c.qop != "" {
+ values = append(values, `qop=`+c.qop, `nc=`+nc, `cnonce=`+strconv.Quote(cnonce))
+ }
+ return "Digest " + strings.Join(values, ", "), nil
+}
+func xmlEscape(value string) string {
+ var b strings.Builder
+ _ = xml.EscapeText(&b, []byte(value))
+ return b.String()
+}
diff --git a/Sense/server/app/sense/onvif/client_test.go b/Sense/server/app/sense/onvif/client_test.go
new file mode 100644
index 0000000..6b1daf3
--- /dev/null
+++ b/Sense/server/app/sense/onvif/client_test.go
@@ -0,0 +1,81 @@
+package onvif
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func loopbackPolicy(t *testing.T) Policy {
+ t.Helper()
+ policy, err := ParsePolicy("127.0.0.0/8")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return policy
+}
+func TestProfilesSupportsDigestNormalizesAdvertisedHostsAndRejectsCredentials(t *testing.T) {
+ digestSeen := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := make([]byte, r.ContentLength)
+ _, _ = r.Body.Read(body)
+ value := string(body)
+ switch {
+ case strings.Contains(value, "GetCapabilities"):
+ fmt.Fprint(w, `http://unusable.invalid/onvif/media`)
+ case !strings.HasPrefix(r.Header.Get("Authorization"), "Digest "):
+ w.Header().Set("WWW-Authenticate", `Digest realm="camera", nonce="n", algorithm=MD5, qop="auth"`)
+ w.WriteHeader(http.StatusUnauthorized)
+ case strings.Contains(value, "GetProfiles"):
+ digestSeen = strings.HasPrefix(r.Header.Get("Authorization"), "Digest ")
+ fmt.Fprint(w, `主码流H26419201080`)
+ default:
+ fmt.Fprint(w, `rtsp://unusable.invalid:8554/live`)
+ }
+ }))
+ defer server.Close()
+ profiles, err := NewHTTPClient(2*time.Second, loopbackPolicy(t)).Profiles(context.Background(), server.URL+"/onvif/device", Credential{Username: "synthetic", Password: "synthetic"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !digestSeen || len(profiles) != 1 || !strings.Contains(profiles[0].StreamURI, "127.0.0.1:8554") {
+ t.Fatalf("profiles=%#v digest=%v", profiles, digestSeen)
+ }
+ if _, _, err = loopbackPolicy(t).ValidateURL(context.Background(), "http://user:pass@127.0.0.1/onvif", "http"); err == nil {
+ t.Fatal("credential URL accepted")
+ }
+ if _, _, err = loopbackPolicy(t).ValidateURL(context.Background(), "http://127.0.0.1/onvif?access_token=synthetic", "http"); err == nil {
+ t.Fatal("credential-like query accepted")
+ }
+}
+func TestPolicyRequiresExplicitCIDRAndRejectsOutsideTarget(t *testing.T) {
+ if _, err := ParsePolicy(""); err == nil {
+ t.Fatal("empty policy accepted")
+ }
+ policy := loopbackPolicy(t)
+ if _, _, err := policy.ValidateURL(context.Background(), "http://192.0.2.1/onvif", "http"); err == nil {
+ t.Fatal("outside target accepted")
+ }
+}
+func TestDiscoveryRequiresApprovedInterface(t *testing.T) {
+ _, err := Discover(context.Background(), "", time.Millisecond, loopbackPolicy(t))
+ if err != ErrDiscoveryNotConfigured {
+ t.Fatalf("error=%v", err)
+ }
+}
+
+func TestClientRejectsRedirect(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Location", "http://127.0.0.1/other")
+ w.WriteHeader(http.StatusFound)
+ }))
+ defer server.Close()
+ _, err := NewHTTPClient(time.Second, loopbackPolicy(t)).Profiles(context.Background(), server.URL+"/onvif", Credential{})
+ if err != ErrRedirect {
+ t.Fatalf("error=%v", err)
+ }
+}
diff --git a/Sense/server/app/sense/onvif/discovery.go b/Sense/server/app/sense/onvif/discovery.go
new file mode 100644
index 0000000..f32d08c
--- /dev/null
+++ b/Sense/server/app/sense/onvif/discovery.go
@@ -0,0 +1,96 @@
+package onvif
+
+import (
+ "context"
+ "encoding/xml"
+ "errors"
+ "io"
+ "net"
+ "sort"
+ "strings"
+ "time"
+)
+
+var (
+ ErrDiscoveryNotConfigured = errors.New("未配置获准的发现网卡")
+ ErrDiscoveryInterface = errors.New("配置的发现地址不是本机网卡")
+)
+
+const discoveryProbe = `uuid:sense-controlled-discoveryurn:schemas-xmlsoap-org:ws:2005:04:discoveryhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probedn:NetworkVideoTransmitter`
+
+func Discover(ctx context.Context, localIP string, timeout time.Duration, policy Policy) ([]string, error) {
+ ip := net.ParseIP(strings.TrimSpace(localIP))
+ if ip == nil {
+ return nil, ErrDiscoveryNotConfigured
+ }
+ approved := false
+ interfaces, _ := net.Interfaces()
+ for _, iface := range interfaces {
+ addresses, _ := iface.Addrs()
+ for _, address := range addresses {
+ host, _, _ := net.ParseCIDR(address.String())
+ if host != nil && host.Equal(ip) {
+ approved = true
+ }
+ }
+ }
+ if !approved {
+ return nil, ErrDiscoveryInterface
+ }
+ connection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip})
+ if err != nil {
+ return nil, err
+ }
+ defer connection.Close()
+ if timeout <= 0 {
+ timeout = 3 * time.Second
+ }
+ _ = connection.SetDeadline(time.Now().Add(timeout))
+ if _, err = connection.WriteToUDP([]byte(discoveryProbe), &net.UDPAddr{IP: net.ParseIP("239.255.255.250"), Port: 3702}); err != nil {
+ return nil, err
+ }
+ found := map[string]bool{}
+ buffer := make([]byte, 65535)
+ for {
+ n, _, readErr := connection.ReadFromUDP(buffer)
+ if readErr != nil {
+ if e, ok := readErr.(net.Error); ok && e.Timeout() {
+ break
+ }
+ return nil, readErr
+ }
+ for _, candidate := range extractXAddrs(string(buffer[:n])) {
+ if _, _, validErr := policy.ValidateURL(ctx, candidate, "http", "https"); validErr == nil {
+ found[candidate] = true
+ }
+ }
+ }
+ result := make([]string, 0, len(found))
+ for value := range found {
+ result = append(result, value)
+ }
+ sort.Strings(result)
+ return result, nil
+}
+
+func extractXAddrs(value string) []string {
+ decoder := xml.NewDecoder(strings.NewReader(value))
+ for {
+ token, err := decoder.Token()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return nil
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok || start.Name.Local != "XAddrs" {
+ continue
+ }
+ var addresses string
+ if decoder.DecodeElement(&addresses, &start) == nil {
+ return strings.Fields(addresses)
+ }
+ }
+ return nil
+}
diff --git a/Sense/server/app/sense/onvif/parser.go b/Sense/server/app/sense/onvif/parser.go
new file mode 100644
index 0000000..80744d7
--- /dev/null
+++ b/Sense/server/app/sense/onvif/parser.go
@@ -0,0 +1,75 @@
+package onvif
+
+import (
+ "encoding/xml"
+ "errors"
+ "io"
+ "strings"
+)
+
+var ErrInvalidResponse = errors.New("ONVIF 返回内容无法识别")
+
+type profileEnvelope struct {
+ Profiles []struct {
+ Token string `xml:"token,attr"`
+ Name string `xml:"Name"`
+ Encoder struct {
+ Encoding string `xml:"Encoding"`
+ Resolution struct {
+ Width int `xml:"Width"`
+ Height int `xml:"Height"`
+ } `xml:"Resolution"`
+ } `xml:"VideoEncoderConfiguration"`
+ } `xml:"Body>GetProfilesResponse>Profiles"`
+}
+
+func parseProfiles(data []byte) ([]Profile, error) {
+ var envelope profileEnvelope
+ if err := xml.Unmarshal(data, &envelope); err != nil || len(envelope.Profiles) == 0 || len(envelope.Profiles) > 128 {
+ return nil, ErrInvalidResponse
+ }
+ result := make([]Profile, 0, len(envelope.Profiles))
+ for _, p := range envelope.Profiles {
+ if strings.TrimSpace(p.Token) == "" || len(p.Token) > 255 || len([]rune(p.Name)) > 255 || p.Encoder.Resolution.Width <= 0 || p.Encoder.Resolution.Width > 32768 || p.Encoder.Resolution.Height <= 0 || p.Encoder.Resolution.Height > 32768 || len(p.Encoder.Encoding) > 32 {
+ continue
+ }
+ result = append(result, Profile{Token: p.Token, Name: p.Name, Width: p.Encoder.Resolution.Width, Height: p.Encoder.Resolution.Height, Encoding: p.Encoder.Encoding})
+ }
+ if len(result) == 0 {
+ return nil, ErrInvalidResponse
+ }
+ return result, nil
+}
+
+func parseElement(data []byte, parent, name string) (string, error) {
+ decoder := xml.NewDecoder(strings.NewReader(string(data)))
+ depth := 0
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return "", ErrInvalidResponse
+ }
+ return "", ErrInvalidResponse
+ }
+ switch value := token.(type) {
+ case xml.StartElement:
+ if parent == "" || value.Name.Local == parent {
+ if value.Name.Local == parent {
+ depth++
+ }
+ }
+ if (parent == "" || depth > 0) && value.Name.Local == name {
+ var text string
+ if err := decoder.DecodeElement(&text, &value); err != nil {
+ return "", ErrInvalidResponse
+ }
+ return strings.TrimSpace(text), nil
+ }
+ case xml.EndElement:
+ if value.Name.Local == parent && depth > 0 {
+ depth--
+ }
+ }
+ }
+}
diff --git a/Sense/server/app/sense/onvif/policy.go b/Sense/server/app/sense/onvif/policy.go
new file mode 100644
index 0000000..21f6071
--- /dev/null
+++ b/Sense/server/app/sense/onvif/policy.go
@@ -0,0 +1,76 @@
+package onvif
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/url"
+ "strings"
+)
+
+var (
+ ErrTargetNotAllowed = errors.New("目标地址不在获准网段内")
+ ErrAddressInvalid = errors.New("设备地址格式不正确")
+)
+
+type Policy struct{ Networks []*net.IPNet }
+
+func ParsePolicy(value string) (Policy, error) {
+ var policy Policy
+ for _, item := range strings.Split(value, ",") {
+ item = strings.TrimSpace(item)
+ if item == "" {
+ continue
+ }
+ _, network, err := net.ParseCIDR(item)
+ if err != nil {
+ return Policy{}, ErrTargetNotAllowed
+ }
+ policy.Networks = append(policy.Networks, network)
+ }
+ if len(policy.Networks) == 0 {
+ return Policy{}, ErrTargetNotAllowed
+ }
+ return policy, nil
+}
+
+func (p Policy) ValidateURL(ctx context.Context, raw string, schemes ...string) (*url.URL, net.IP, error) {
+ u, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
+ return nil, nil, ErrAddressInvalid
+ }
+ for key := range u.Query() {
+ lower := strings.ToLower(key)
+ for _, sensitive := range []string{"user", "password", "passwd", "token", "auth", "credential", "secret", "key"} {
+ if strings.Contains(lower, sensitive) {
+ return nil, nil, ErrAddressInvalid
+ }
+ }
+ }
+ allowedScheme := false
+ for _, scheme := range schemes {
+ if strings.EqualFold(u.Scheme, scheme) {
+ allowedScheme = true
+ }
+ }
+ if !allowedScheme {
+ return nil, nil, ErrAddressInvalid
+ }
+ addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", u.Hostname())
+ if err != nil || len(addresses) == 0 {
+ return nil, nil, ErrTargetNotAllowed
+ }
+ for _, address := range addresses {
+ ok := false
+ for _, network := range p.Networks {
+ if network.Contains(address) {
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ return nil, nil, ErrTargetNotAllowed
+ }
+ }
+ return u, addresses[0], nil
+}
diff --git a/Sense/server/app/sense/rtsp/verifier.go b/Sense/server/app/sense/rtsp/verifier.go
new file mode 100644
index 0000000..cd814b6
--- /dev/null
+++ b/Sense/server/app/sense/rtsp/verifier.go
@@ -0,0 +1,70 @@
+package rtsp
+
+import (
+ "bufio"
+ "context"
+ "encoding/base64"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
+)
+
+type Credential struct{ Username, Password string }
+type Result struct {
+ Status string `json:"status"`
+ LatencyMS int64 `json:"latencyMs"`
+ Detail string `json:"detail"`
+}
+type Verifier interface {
+ Verify(context.Context, string, Credential) (Result, error)
+}
+type NetVerifier struct {
+ Timeout time.Duration
+ Policy onvif.Policy
+}
+
+func (v NetVerifier) Verify(ctx context.Context, raw string, credential Credential) (Result, error) {
+ parsed, ip, err := v.Policy.ValidateURL(ctx, raw, "rtsp")
+ if err != nil {
+ return Result{}, err
+ }
+ port := parsed.Port()
+ if port == "" {
+ port = "554"
+ }
+ timeout := v.Timeout
+ if timeout <= 0 {
+ timeout = 5 * time.Second
+ }
+ started := time.Now()
+ connection, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), port))
+ if err != nil {
+ return Result{Status: "unreachable", Detail: "无法连接视频端口"}, nil
+ }
+ defer connection.Close()
+ _ = connection.SetDeadline(time.Now().Add(timeout))
+ authorization := ""
+ if credential.Username != "" {
+ authorization = "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(credential.Username+":"+credential.Password)) + "\r\n"
+ }
+ request := fmt.Sprintf("OPTIONS %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: YoVision-Sense\r\n%s\r\n", parsed.String(), authorization)
+ if _, err = connection.Write([]byte(request)); err != nil {
+ return Result{}, err
+ }
+ line, err := bufio.NewReader(connection).ReadString('\n')
+ if err != nil {
+ return Result{Status: "timeout", Detail: "等待视频响应超时"}, nil
+ }
+ result := Result{Status: "ready", Detail: "码流可访问", LatencyMS: time.Since(started).Milliseconds()}
+ if strings.Contains(line, " 401 ") {
+ result.Status = "authentication_failed"
+ result.Detail = "设备拒绝了当前 RTSP 凭据"
+ } else if !strings.Contains(line, " 200 ") {
+ result.Status = "failed"
+ result.Detail = "设备返回非成功状态"
+ }
+ return result, nil
+}
diff --git a/Sense/server/app/sense/rtsp/verifier_test.go b/Sense/server/app/sense/rtsp/verifier_test.go
new file mode 100644
index 0000000..8b192f0
--- /dev/null
+++ b/Sense/server/app/sense/rtsp/verifier_test.go
@@ -0,0 +1,55 @@
+package rtsp
+
+import (
+ "bufio"
+ "context"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
+)
+
+func TestVerifierUsesCredentialHeaderWithoutPuttingItInURI(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ requestChannel := make(chan string, 1)
+ go func() {
+ connection, _ := listener.Accept()
+ if connection == nil {
+ return
+ }
+ defer connection.Close()
+ reader := bufio.NewReader(connection)
+ request, _ := reader.ReadString('\n')
+ headers := request
+ for {
+ line, _ := reader.ReadString('\n')
+ headers += line
+ if line == "\r\n" || line == "" {
+ break
+ }
+ }
+ requestChannel <- headers
+ _, _ = connection.Write([]byte("RTSP/1.0 200 OK\r\nCSeq: 1\r\n\r\n"))
+ }()
+ policy, _ := onvif.ParsePolicy("127.0.0.0/8")
+ result, err := (NetVerifier{Timeout: time.Second, Policy: policy}).Verify(context.Background(), "rtsp://"+listener.Addr().String()+"/live", Credential{Username: "synthetic", Password: "synthetic"})
+ if err != nil || result.Status != "ready" {
+ t.Fatalf("result=%#v err=%v", result, err)
+ }
+ request := <-requestChannel
+ if strings.Contains(strings.Split(request, "\r\n")[0], "synthetic") {
+ t.Fatal("credentials leaked into request URI")
+ }
+ if !strings.Contains(request, "Authorization: Basic ") {
+ t.Fatal("authorization header missing")
+ }
+ if _, err = (NetVerifier{Policy: policy}).Verify(context.Background(), "rtsp://user:pass@"+listener.Addr().String()+"/live", Credential{}); err == nil {
+ t.Fatal("credential-bearing URI accepted")
+ }
+}
diff --git a/Sense/server/cmd/migrate/migration/version/2026081417000_profile.go b/Sense/server/cmd/migrate/migration/version/2026081417000_profile.go
new file mode 100644
index 0000000..b651a3d
--- /dev/null
+++ b/Sense/server/cmd/migrate/migration/version/2026081417000_profile.go
@@ -0,0 +1,60 @@
+package version
+
+import (
+ "runtime"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
+ "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
+ migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
+ common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
+)
+
+func init() {
+ _, fileName, _, _ := runtime.Caller(0)
+ migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseAdmission)
+}
+func migrateSenseAdmission(db *gorm.DB, version string) error {
+ return db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.AutoMigrate(&admission.Result{}, &admission.Profile{}); err != nil {
+ return err
+ }
+ page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmission", Title: "视频接入", Icon: "video-camera", Path: "/sense/admission", MenuType: "C", Permission: "sense:admission:list", Component: "/sense/admission/index", Sort: 6, Visible: "0", IsFrame: "1"})
+ if err != nil {
+ return err
+ }
+ discover, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmissionDiscover", Title: "发现设备", MenuType: "F", Action: "GET", Permission: "sense:admission:discover", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
+ if err != nil {
+ return err
+ }
+ probe, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmissionProbe", Title: "验证接入", MenuType: "F", Action: "POST", Permission: "sense:admission:probe", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
+ if err != nil {
+ return err
+ }
+ for _, role := range []string{"implementation_operator", "site_admin"} {
+ if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, discover, probe}); err != nil {
+ return err
+ }
+ }
+ if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
+ return err
+ }
+ read := [][2]string{{"/api/v1/admission/devices/:id", "GET"}}
+ write := [][2]string{{"/api/v1/admission/discover", "GET"}, {"/api/v1/admission/devices/:id/probe", "POST"}}
+ for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
+ policies := append([][2]string{}, read...)
+ if role != "viewer" {
+ policies = append(policies, write...)
+ }
+ for _, policy := range policies {
+ rule := deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}
+ if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rule).Error; err != nil {
+ return err
+ }
+ }
+ }
+ return tx.Create(&common.Migration{Version: version}).Error
+ })
+}
diff --git a/Sense/server/config/credential.env.example b/Sense/server/config/credential.env.example
index 3825f79..99883ea 100644
--- a/Sense/server/config/credential.env.example
+++ b/Sense/server/config/credential.env.example
@@ -1,3 +1,8 @@
# Required only when creating or updating camera credentials.
# Set outside the repository to a Base64-encoded random 32-byte key.
SENSE_CREDENTIAL_KEY=
+
+# Required before ONVIF discovery or manual probing. Use only explicitly
+# approved local interface/IP ranges; comma-separate multiple CIDRs.
+SENSE_ONVIF_DISCOVERY_IP=
+SENSE_ONVIF_ALLOWED_CIDRS=
diff --git a/Sense/server/tests/admission/postgres_test.go b/Sense/server/tests/admission/postgres_test.go
new file mode 100644
index 0000000..ff75f85
--- /dev/null
+++ b/Sense/server/tests/admission/postgres_test.go
@@ -0,0 +1,85 @@
+package admission_test
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "os"
+ "testing"
+
+ coreService "github.com/go-admin-team/go-admin-core/sdk/service"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
+ deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
+ "git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
+)
+
+type onvifFixture struct{}
+
+func (onvifFixture) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
+ return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/sub"}}, nil
+}
+
+type rtspFixture struct{}
+
+func (rtspFixture) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
+ return rtsp.Result{Status: "ready", Detail: "合成 RTSP 可用"}, nil
+}
+
+func TestProfilesSurvivePostgreSQLReopen(t *testing.T) {
+ dsn := os.Getenv("SENSE_ADMISSION_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("set SENSE_ADMISSION_TEST_DATABASE_URL to an isolated PostgreSQL database")
+ }
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = db.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &admission.Result{}, &admission.Profile{}); err != nil {
+ t.Fatal(err)
+ }
+ deviceID := "issue66-postgres-device"
+ db.Where("device_id = ?", deviceID).Delete(&admission.Profile{})
+ db.Where("device_id = ?", deviceID).Delete(&admission.Result{})
+ db.Where("device_id = ?", deviceID).Delete(&credential.DeviceCredential{})
+ db.Where("id = ?", deviceID).Delete(&deviceModels.Device{})
+ t.Cleanup(func() {
+ db.Where("device_id = ?", deviceID).Delete(&admission.Profile{})
+ db.Where("device_id = ?", deviceID).Delete(&admission.Result{})
+ db.Where("device_id = ?", deviceID).Delete(&credential.DeviceCredential{})
+ db.Where("id = ?", deviceID).Delete(&deviceModels.Device{})
+ })
+ key := make([]byte, 32)
+ if _, err = rand.Read(key); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
+ vault, _ := credential.NewVault(key)
+ device := deviceModels.Device{ID: deviceID, Name: "重启持久化摄像机", Modality: deviceModels.ModalityVideo, Status: deviceModels.StatusPending, AdapterStatus: deviceModels.AdapterReady, Version: 1}
+ if err = db.Create(&device).Error; err != nil {
+ t.Fatal(err)
+ }
+ for _, purpose := range []string{credential.PurposeONVIF, credential.PurposeRTSP} {
+ cipher, _ := vault.Encrypt(deviceID, purpose, "synthetic-user", "synthetic-password")
+ if err = db.Create(&credential.DeviceCredential{DeviceID: deviceID, Purpose: purpose, Ciphertext: cipher, KeyVersion: credential.Version()}).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ service := admission.Service{Service: coreService.Service{Orm: db}, ONVIF: onvifFixture{}, RTSP: rtspFixture{}}
+ if _, err = service.Probe(context.Background(), admission.ProbeRequest{DeviceID: deviceID, Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
+ t.Fatal(err)
+ }
+ reopened, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ restarted := admission.Service{Service: coreService.Service{Orm: reopened}}
+ saved, err := restarted.Get(deviceID)
+ if err != nil || saved.Status != "ready" || len(saved.Profiles) != 2 {
+ t.Fatalf("saved=%#v err=%v", saved, err)
+ }
+}
diff --git a/Sense/ui/src/api/sense/admission.js b/Sense/ui/src/api/sense/admission.js
new file mode 100644
index 0000000..de02cd3
--- /dev/null
+++ b/Sense/ui/src/api/sense/admission.js
@@ -0,0 +1,5 @@
+import request from '@/utils/request'
+
+export function discoverDevices() { return request({ url: '/api/v1/admission/discover', method: 'get' }) }
+export function getAdmission(deviceId) { return request({ url: `/api/v1/admission/devices/${deviceId}`, method: 'get' }) }
+export function probeDevice(deviceId, data) { return request({ url: `/api/v1/admission/devices/${deviceId}/probe`, method: 'post', data }) }
diff --git a/Sense/ui/src/views/sense/admission/admissionPayload.js b/Sense/ui/src/views/sense/admission/admissionPayload.js
new file mode 100644
index 0000000..4f9c87d
--- /dev/null
+++ b/Sense/ui/src/views/sense/admission/admissionPayload.js
@@ -0,0 +1,7 @@
+export function buildProbePayload(form) {
+ return { address: String(form.address || '').trim(), version: Number(form.version) }
+}
+
+export function addressHasCredentials(value) {
+ try { return Boolean(new URL(value).username || new URL(value).password) } catch (_) { return false }
+}
diff --git a/Sense/ui/src/views/sense/admission/index.vue b/Sense/ui/src/views/sense/admission/index.vue
new file mode 100644
index 0000000..565dead
--- /dev/null
+++ b/Sense/ui/src/views/sense/admission/index.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+ 未配置凭据或已停用的设备不可探测。
+
+
+
+ 地址中不能包含用户名或密码;凭据从设备管理安全读取。
+
+
+ 验证接入
+ 查看上次结果
+
+
+
+
+
+
+ {{ statusLabel(result.status) }}
+ {{ parseTime(result.checkedAt) }}
+ {{ result.detail }}
+
+
+
+ {{ scope.row.kind === 'main' ? '主码流' : scope.row.kind === 'sub' ? '子码流' : '其他' }}
+ {{ scope.row.width }} × {{ scope.row.height }}
+
+ {{ statusLabel(scope.row.verificationStatus) }}
+
+
+
+
+
+
+
+
+ 使用
+
+
+
+
+
+
+
+
+
diff --git a/Sense/ui/src/views/sense/device/index.vue b/Sense/ui/src/views/sense/device/index.vue
index 871bed4..125597f 100644
--- a/Sense/ui/src/views/sense/device/index.vue
+++ b/Sense/ui/src/views/sense/device/index.vue
@@ -14,6 +14,7 @@
+
@@ -54,7 +55,7 @@
- {{ scope.row.status === 'disabled' ? '已停用' : '待接入' }}
+ {{ scope.row.status === 'active' ? '已接入' : scope.row.status === 'disabled' ? '已停用' : '待接入' }}
diff --git a/Sense/ui/tests/unit/sense/admissionPayload.spec.js b/Sense/ui/tests/unit/sense/admissionPayload.spec.js
new file mode 100644
index 0000000..2dad6a7
--- /dev/null
+++ b/Sense/ui/tests/unit/sense/admissionPayload.spec.js
@@ -0,0 +1,11 @@
+import { addressHasCredentials, buildProbePayload } from '@/views/sense/admission/admissionPayload'
+
+describe('Sense admission payload', () => {
+ it('only sends the approved probe fields', () => {
+ expect(buildProbePayload({ address: ' http://192.0.2.1/onvif ', version: '3', password: 'never-send' })).toEqual({ address: 'http://192.0.2.1/onvif', version: 3 })
+ })
+ it('detects credentials embedded in a URL', () => {
+ expect(addressHasCredentials('http://user:secret@192.0.2.1/onvif')).toBe(true)
+ expect(addressHasCredentials('http://192.0.2.1/onvif')).toBe(false)
+ })
+})