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 @@ + + + + + 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 @@ 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) + }) +})