Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab54235819 | ||
|
|
d74e1aee85 | ||
|
|
26e2e632d5 | ||
|
|
2bb1614a3c | ||
|
|
5856b76de9 | ||
|
|
3e859c3848 | ||
|
|
f7eab5d8fa | ||
|
|
d31c35f098 | ||
|
|
be091f093d | ||
|
|
1ef4a1fe0b |
+5
-1
@@ -40,4 +40,8 @@ corepack pnpm@9.15.1 build:prod
|
||||
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 的本地开发与验证页为准。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
senseapis "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() {
|
||||
routerCheckRole = append(routerCheckRole, registerSenseDeviceRouter)
|
||||
}
|
||||
|
||||
func registerSenseDeviceRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
api := senseapis.Device{}
|
||||
r := v1.Group("/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
{
|
||||
r.GET("", api.GetPage)
|
||||
r.GET("/:id", api.Get)
|
||||
r.POST("", api.Insert)
|
||||
r.PUT("/:id", api.Update)
|
||||
r.PUT("/:id/disable", api.Disable)
|
||||
r.PUT("/:id/credentials", api.UpdateCredentials)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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", "无法读取设备信息,请检查地址和网络"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvironmentKey = "SENSE_CREDENTIAL_KEY"
|
||||
PurposeONVIF = "onvif"
|
||||
PurposeRTSP = "rtsp"
|
||||
keyVersion = "v1"
|
||||
)
|
||||
|
||||
var ErrKeyUnavailable = errors.New("摄像头凭据加密密钥不可用")
|
||||
|
||||
// DeviceCredential is deliberately stored separately from the device ledger.
|
||||
// No HTTP response type embeds this model.
|
||||
type DeviceCredential struct {
|
||||
DeviceID string `gorm:"size:36;primaryKey" json:"-"`
|
||||
Purpose string `gorm:"size:16;primaryKey" json:"-"`
|
||||
Ciphertext []byte `gorm:"type:bytea;not null" json:"-"`
|
||||
KeyVersion string `gorm:"size:16;not null" json:"-"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
func (DeviceCredential) TableName() string { return "sense_device_credentials" }
|
||||
|
||||
type Vault struct{ key []byte }
|
||||
|
||||
func NewVaultFromEnvironment() (*Vault, error) {
|
||||
encoded := strings.TrimSpace(os.Getenv(EnvironmentKey))
|
||||
if encoded == "" {
|
||||
return nil, ErrKeyUnavailable
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
return nil, ErrKeyUnavailable
|
||||
}
|
||||
return NewVault(decoded)
|
||||
}
|
||||
|
||||
func NewVault(key []byte) (*Vault, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, ErrKeyUnavailable
|
||||
}
|
||||
copyOfKey := append([]byte(nil), key...)
|
||||
return &Vault{key: copyOfKey}, nil
|
||||
}
|
||||
|
||||
func (v *Vault) Encrypt(deviceID, purpose, username, password string) ([]byte, error) {
|
||||
if err := validateScope(deviceID, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plaintext, err := json.Marshal(struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}{Username: username, Password: password})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode credential: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize credential cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize credential gcm: %w", err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("generate credential nonce: %w", err)
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, associatedData(deviceID, purpose)), nil
|
||||
}
|
||||
|
||||
// Decrypt is an internal adapter boundary. It is intentionally not exposed by
|
||||
// any Sense HTTP handler and must only be used for the matching device/purpose.
|
||||
func (v *Vault) Decrypt(deviceID, purpose string, ciphertext []byte) (string, string, error) {
|
||||
if err := validateScope(deviceID, purpose); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("initialize credential cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("initialize credential gcm: %w", err)
|
||||
}
|
||||
if len(ciphertext) < gcm.NonceSize() {
|
||||
return "", "", errors.New("invalid credential ciphertext")
|
||||
}
|
||||
plaintext, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], associatedData(deviceID, purpose))
|
||||
if err != nil {
|
||||
return "", "", errors.New("credential ciphertext does not match device purpose")
|
||||
}
|
||||
var value struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err = json.Unmarshal(plaintext, &value); err != nil {
|
||||
return "", "", errors.New("invalid credential payload")
|
||||
}
|
||||
return value.Username, value.Password, nil
|
||||
}
|
||||
|
||||
func Version() string { return keyVersion }
|
||||
|
||||
func validateScope(deviceID, purpose string) error {
|
||||
if strings.TrimSpace(deviceID) == "" {
|
||||
return errors.New("device id is required")
|
||||
}
|
||||
if purpose != PurposeONVIF && purpose != PurposeRTSP {
|
||||
return errors.New("unsupported credential purpose")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func associatedData(deviceID, purpose string) []byte {
|
||||
return []byte("sense-device-credential-v1\x00" + deviceID + "\x00" + purpose)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVaultSeparatesDeviceAndPurpose(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault, err := NewVault(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, err := vault.Encrypt("device-a", PurposeONVIF, "synthetic-user", "synthetic-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
username, password, err := vault.Decrypt("device-a", PurposeONVIF, ciphertext)
|
||||
if err != nil || username != "synthetic-user" || password != "synthetic-password" {
|
||||
t.Fatalf("credential round trip failed: username=%q err=%v", username, err)
|
||||
}
|
||||
if _, _, err = vault.Decrypt("device-a", PurposeRTSP, ciphertext); err == nil {
|
||||
t.Fatal("credential ciphertext was reusable for another purpose")
|
||||
}
|
||||
if _, _, err = vault.Decrypt("device-b", PurposeONVIF, ciphertext); err == nil {
|
||||
t.Fatal("credential ciphertext was reusable for another device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultRejectsInvalidKeyLength(t *testing.T) {
|
||||
if _, err := NewVault(make([]byte, 16)); err == nil {
|
||||
t.Fatal("short credential key accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package apis
|
||||
|
||||
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"
|
||||
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
)
|
||||
|
||||
type Device struct{ api.Api }
|
||||
|
||||
func (e Device) GetPage(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
req := dto.PageReq{}
|
||||
if err := e.MakeContext(c).MakeOrm().Bind(&req).MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
|
||||
return
|
||||
}
|
||||
list := make([]dto.DeviceResponse, 0)
|
||||
var count int64
|
||||
if err := service.GetPage(&req, &list, &count); err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "设备列表查询失败")
|
||||
return
|
||||
}
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
func (e Device) Get(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Get(c.Param("id"), &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "查询成功")
|
||||
}
|
||||
|
||||
func (e Device) Insert(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.CreateReq{}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
req.CreateBy = user.GetUserId(c)
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Insert(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "设备创建成功")
|
||||
}
|
||||
|
||||
func (e Device) Update(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.UpdateReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Update(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "设备更新成功")
|
||||
}
|
||||
|
||||
func (e Device) Disable(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.DisableReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Disable(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "设备已停用")
|
||||
}
|
||||
|
||||
func (e Device) UpdateCredentials(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.CredentialUpdateReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.UpdateCredentials(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "凭据已安全更新,已请求重新验证")
|
||||
}
|
||||
|
||||
func (e Device) writeServiceError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, deviceService.ErrInvalidDevice):
|
||||
e.Error(http.StatusBadRequest, err, "设备信息不符合要求")
|
||||
case errors.Is(err, deviceService.ErrDeviceNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, deviceService.ErrVersionConflict):
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
case errors.Is(err, credential.ErrKeyUnavailable):
|
||||
e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "设备操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
func bindStrictJSON(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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestBindStrictJSONRejectsUnknownDeviceField(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest("POST", "/api/v1/devices", strings.NewReader(`{"name":"测试设备","location":"东门","modality":"video","capabilities":["video"],"password":"must-not-be-accepted"}`))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
var req dto.CreateReq
|
||||
if err := bindStrictJSON(ctx, &req); err == nil {
|
||||
t.Fatal("unknown credential-like field was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
const (
|
||||
ModalityVideo = "video"
|
||||
StatusPending = "pending"
|
||||
StatusActive = "active"
|
||||
StatusDisabled = "disabled"
|
||||
AdapterReady = "ready"
|
||||
AdapterFailed = "verification_failed"
|
||||
AdapterNotReady = "adapter_not_ready"
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Location string `gorm:"size:255;not null;default:''" json:"location"`
|
||||
Modality string `gorm:"size:32;not null;index" json:"modality"`
|
||||
CapabilitiesJSON string `gorm:"column:capabilities;type:jsonb;not null;default:'[]'" json:"-"`
|
||||
Status string `gorm:"size:32;not null;index" json:"status"`
|
||||
AdapterStatus string `gorm:"size:32;not null" json:"adapterStatus"`
|
||||
RTSPSameAsONVIF bool `gorm:"not null;default:true" json:"rtspCredentialSameAsOnvif"`
|
||||
CredentialUpdatedAt *time.Time `json:"credentialUpdatedAt,omitempty"`
|
||||
RetryRequestedAt *time.Time `json:"retryRequestedAt,omitempty"`
|
||||
Version int64 `gorm:"not null;default:1" json:"version"`
|
||||
common.ControlBy
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
func (Device) TableName() string { return "sense_devices" }
|
||||
@@ -0,0 +1,314 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDeviceNotFound = errors.New("设备不存在")
|
||||
ErrVersionConflict = errors.New("设备已被其他用户更新,请刷新后重试")
|
||||
ErrInvalidDevice = errors.New("设备信息不符合要求")
|
||||
)
|
||||
|
||||
var supportedValues = map[string]struct{}{
|
||||
"video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
coreService.Service
|
||||
VaultFactory func() (*credential.Vault, error)
|
||||
}
|
||||
|
||||
type credentialPresence struct {
|
||||
DeviceID string
|
||||
Purpose string
|
||||
}
|
||||
|
||||
func (e *Device) GetPage(req *dto.PageReq, list *[]dto.DeviceResponse, count *int64) error {
|
||||
query := e.Orm.Model(&models.Device{})
|
||||
if keyword := strings.TrimSpace(req.Keyword); keyword != "" {
|
||||
pattern := "%" + strings.ToLower(keyword) + "%"
|
||||
query = query.Where("LOWER(name) LIKE ? OR LOWER(location) LIKE ?", pattern, pattern)
|
||||
}
|
||||
if req.Modality != "" {
|
||||
query = query.Where("modality = ?", req.Modality)
|
||||
}
|
||||
if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
if err := query.Count(count).Error; err != nil {
|
||||
return fmt.Errorf("count devices: %w", err)
|
||||
}
|
||||
pageSize := req.GetPageSize()
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
req.PageSize = pageSize
|
||||
var devices []models.Device
|
||||
if err := query.Order("created_at DESC").Limit(pageSize).Offset((req.GetPageIndex() - 1) * pageSize).Find(&devices).Error; err != nil {
|
||||
return fmt.Errorf("list devices: %w", err)
|
||||
}
|
||||
responses, err := e.responses(devices)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*list = responses
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Device) Get(id string, response *dto.DeviceResponse) error {
|
||||
var model models.Device
|
||||
if err := e.Orm.First(&model, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return fmt.Errorf("get device: %w", err)
|
||||
}
|
||||
responses, err := e.responses([]models.Device{model})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*response = responses[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Device) Insert(req *dto.CreateReq, response *dto.DeviceResponse) error {
|
||||
name, location, modality, capabilities, err := normalizeDevice(req.Name, req.Location, req.Modality, req.Capabilities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, _ := json.Marshal(capabilities)
|
||||
adapterStatus := models.AdapterNotReady
|
||||
if modality == models.ModalityVideo {
|
||||
adapterStatus = models.AdapterReady
|
||||
}
|
||||
model := models.Device{
|
||||
ID: uuid.NewString(),
|
||||
Name: name,
|
||||
Location: location,
|
||||
Modality: modality,
|
||||
CapabilitiesJSON: string(encoded),
|
||||
Status: models.StatusPending,
|
||||
AdapterStatus: adapterStatus,
|
||||
RTSPSameAsONVIF: true,
|
||||
Version: 1,
|
||||
}
|
||||
model.CreateBy = req.CreateBy
|
||||
model.UpdateBy = req.CreateBy
|
||||
if err = e.Orm.Create(&model).Error; err != nil {
|
||||
return fmt.Errorf("create device: %w", err)
|
||||
}
|
||||
return e.Get(model.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) Update(req *dto.UpdateReq, response *dto.DeviceResponse) error {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" || len([]rune(name)) > 128 || len([]rune(req.Location)) > 255 || req.Version < 1 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
capabilities, err := normalizeCapabilities(req.Capabilities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, _ := json.Marshal(capabilities)
|
||||
updates := map[string]any{
|
||||
"name": name, "location": strings.TrimSpace(req.Location), "capabilities": string(encoded),
|
||||
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
|
||||
}
|
||||
result := e.Orm.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("update device: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return e.notFoundOrConflict(req.ID)
|
||||
}
|
||||
return e.Get(req.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) Disable(req *dto.DisableReq, response *dto.DeviceResponse) error {
|
||||
if req.Version < 1 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
result := e.Orm.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(map[string]any{
|
||||
"status": models.StatusDisabled, "version": req.Version + 1,
|
||||
"update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("disable device: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return e.notFoundOrConflict(req.ID)
|
||||
}
|
||||
return e.Get(req.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) UpdateCredentials(req *dto.CredentialUpdateReq, response *dto.DeviceResponse) error {
|
||||
if req.Version < 1 || strings.TrimSpace(req.ONVIFUsername) == "" || req.ONVIFPassword == "" {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
var target models.Device
|
||||
if err := e.Orm.Select("id", "modality").First(&target, "id = ?", req.ID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return fmt.Errorf("read credential target: %w", err)
|
||||
}
|
||||
if target.Modality != models.ModalityVideo {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
if len(req.ONVIFUsername) > 255 || len(req.ONVIFPassword) > 1024 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
rtspUsername, rtspPassword := req.RTSPUsername, req.RTSPPassword
|
||||
if req.RTSPSameAsONVIF {
|
||||
rtspUsername, rtspPassword = req.ONVIFUsername, req.ONVIFPassword
|
||||
} else if strings.TrimSpace(rtspUsername) == "" || rtspPassword == "" || len(rtspUsername) > 255 || len(rtspPassword) > 1024 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
factory := e.VaultFactory
|
||||
if factory == nil {
|
||||
factory = credential.NewVaultFromEnvironment
|
||||
}
|
||||
vault, err := factory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
onvifCiphertext, err := vault.Encrypt(req.ID, credential.PurposeONVIF, strings.TrimSpace(req.ONVIFUsername), req.ONVIFPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rtspCiphertext, err := vault.Encrypt(req.ID, credential.PurposeRTSP, strings.TrimSpace(rtspUsername), rtspPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
err = e.Orm.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(map[string]any{
|
||||
"rtsp_same_as_onvif": req.RTSPSameAsONVIF,
|
||||
"credential_updated_at": now, "retry_requested_at": now,
|
||||
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return e.notFoundOrConflictWith(tx, req.ID)
|
||||
}
|
||||
rows := []credential.DeviceCredential{
|
||||
{DeviceID: req.ID, Purpose: credential.PurposeONVIF, Ciphertext: onvifCiphertext, KeyVersion: credential.Version()},
|
||||
{DeviceID: req.ID, Purpose: credential.PurposeRTSP, Ciphertext: rtspCiphertext, KeyVersion: credential.Version()},
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "device_id"}, {Name: "purpose"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"ciphertext", "key_version", "updated_at"}),
|
||||
}).Create(&rows).Error
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrVersionConflict) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("update device credential: %w", err)
|
||||
}
|
||||
return e.Get(req.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) responses(devices []models.Device) ([]dto.DeviceResponse, error) {
|
||||
result := make([]dto.DeviceResponse, 0, len(devices))
|
||||
if len(devices) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
ids := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
ids = append(ids, device.ID)
|
||||
}
|
||||
var presence []credentialPresence
|
||||
if err := e.Orm.Model(&credential.DeviceCredential{}).Select("device_id", "purpose").Where("device_id IN ?", ids).Find(&presence).Error; err != nil {
|
||||
return nil, fmt.Errorf("read credential status: %w", err)
|
||||
}
|
||||
configured := make(map[string]map[string]bool, len(devices))
|
||||
for _, row := range presence {
|
||||
if configured[row.DeviceID] == nil {
|
||||
configured[row.DeviceID] = map[string]bool{}
|
||||
}
|
||||
configured[row.DeviceID][row.Purpose] = true
|
||||
}
|
||||
for _, device := range devices {
|
||||
var capabilities []string
|
||||
if err := json.Unmarshal([]byte(device.CapabilitiesJSON), &capabilities); err != nil {
|
||||
return nil, fmt.Errorf("decode device capabilities: %w", err)
|
||||
}
|
||||
result = append(result, dto.DeviceResponse{
|
||||
ID: device.ID, Name: device.Name, Location: device.Location, Modality: device.Modality,
|
||||
Capabilities: capabilities, Status: device.Status, AdapterStatus: device.AdapterStatus,
|
||||
ONVIFCredentialConfigured: configured[device.ID][credential.PurposeONVIF],
|
||||
RTSPCredentialConfigured: configured[device.ID][credential.PurposeRTSP],
|
||||
RTSPCredentialSameAsONVIF: device.RTSPSameAsONVIF,
|
||||
RetryPending: device.RetryRequestedAt != nil, Version: device.Version,
|
||||
CreatedAt: device.CreatedAt, UpdatedAt: device.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Device) notFoundOrConflict(id string) error { return e.notFoundOrConflictWith(e.Orm, id) }
|
||||
|
||||
func (e *Device) notFoundOrConflictWith(db *gorm.DB, id string) error {
|
||||
var count int64
|
||||
if err := db.Model(&models.Device{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return ErrVersionConflict
|
||||
}
|
||||
|
||||
func normalizeDevice(name, location, modality string, capabilities []string) (string, string, string, []string, error) {
|
||||
name, location, modality = strings.TrimSpace(name), strings.TrimSpace(location), strings.TrimSpace(modality)
|
||||
if modality == "" {
|
||||
modality = models.ModalityVideo
|
||||
}
|
||||
if name == "" || len([]rune(name)) > 128 || len([]rune(location)) > 255 {
|
||||
return "", "", "", nil, ErrInvalidDevice
|
||||
}
|
||||
if _, ok := supportedValues[modality]; !ok {
|
||||
return "", "", "", nil, ErrInvalidDevice
|
||||
}
|
||||
if len(capabilities) == 0 {
|
||||
capabilities = []string{modality}
|
||||
}
|
||||
normalized, err := normalizeCapabilities(capabilities)
|
||||
return name, location, modality, normalized, err
|
||||
}
|
||||
|
||||
func normalizeCapabilities(values []string) ([]string, error) {
|
||||
if len(values) == 0 || len(values) > 16 {
|
||||
return nil, ErrInvalidDevice
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if _, ok := supportedValues[value]; !ok {
|
||||
return nil, ErrInvalidDevice
|
||||
}
|
||||
if !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"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"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
)
|
||||
|
||||
func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault, err := credential.NewVault(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Device{Service: coreService.Service{Orm: db}, VaultFactory: func() (*credential.Vault, error) { return vault, nil }}
|
||||
return service, db
|
||||
}
|
||||
|
||||
func TestDeviceLifecycleUsesAllowlistedFieldsAndOptimisticVersion(t *testing.T) {
|
||||
service, _ := testDeviceService(t)
|
||||
var created dto.DeviceResponse
|
||||
err := service.Insert(&dto.CreateReq{Name: "东门摄像机", Location: "教学楼一楼东门", Modality: "video", Capabilities: []string{"video"}, CreateBy: 7}, &created)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Name != "东门摄像机" || created.Location != "教学楼一楼东门" || created.Version != 1 || created.AdapterStatus != models.AdapterReady {
|
||||
t.Fatalf("unexpected device: %#v", created)
|
||||
}
|
||||
var updated dto.DeviceResponse
|
||||
err = service.Update(&dto.UpdateReq{ID: created.ID, Name: "东门主摄像机", Location: "教学楼一楼东门", Capabilities: []string{"video"}, Version: 1, UpdateBy: 8}, &updated)
|
||||
if err != nil || updated.Version != 2 || updated.Name != "东门主摄像机" {
|
||||
t.Fatalf("update failed: device=%#v err=%v", updated, err)
|
||||
}
|
||||
err = service.Update(&dto.UpdateReq{ID: created.ID, Name: "过期写入", Capabilities: []string{"video"}, Version: 1}, &updated)
|
||||
if err != ErrVersionConflict {
|
||||
t.Fatalf("stale update error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialIsPurposeSeparatedNeverReturnedAndRequestsRetry(t *testing.T) {
|
||||
service, db := testDeviceService(t)
|
||||
var created dto.DeviceResponse
|
||||
if err := service.Insert(&dto.CreateReq{Name: "测试摄像机", Modality: "video", Capabilities: []string{"video"}}, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updated dto.DeviceResponse
|
||||
if err := service.UpdateCredentials(&dto.CredentialUpdateReq{
|
||||
ID: created.ID, ONVIFUsername: "synthetic-onvif-user", ONVIFPassword: "synthetic-onvif-password",
|
||||
RTSPSameAsONVIF: false, RTSPUsername: "synthetic-rtsp-user", RTSPPassword: "synthetic-rtsp-password", Version: created.Version,
|
||||
}, &updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.ONVIFCredentialConfigured || !updated.RTSPCredentialConfigured || !updated.RetryPending || updated.Version != 2 {
|
||||
t.Fatalf("credential status not reflected: %#v", updated)
|
||||
}
|
||||
encoded, err := json.Marshal(updated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secretPart := range []string{"synthetic-onvif", "synthetic-rtsp", "ciphertext", "password", "username"} {
|
||||
if strings.Contains(strings.ToLower(string(encoded)), secretPart) {
|
||||
t.Fatalf("response leaked credential material: %s", encoded)
|
||||
}
|
||||
}
|
||||
var stored []credential.DeviceCredential
|
||||
if err = db.Order("purpose").Find(&stored).Error; err != nil || len(stored) != 2 {
|
||||
t.Fatalf("stored credentials=%d err=%v", len(stored), err)
|
||||
}
|
||||
if string(stored[0].Ciphertext) == string(stored[1].Ciphertext) {
|
||||
t.Fatal("ONVIF and RTSP credentials were not purpose-separated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedModalityIsExplicitlyNotReadyAndPaginationExceedsDefaultQuota(t *testing.T) {
|
||||
service, _ := testDeviceService(t)
|
||||
var radar dto.DeviceResponse
|
||||
for index := 0; index < 20; index++ {
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Insert(&dto.CreateReq{Name: "雷达" + strings.Repeat("号", index+1), Modality: "radar", Capabilities: []string{"radar"}}, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.AdapterStatus != models.AdapterNotReady {
|
||||
t.Fatalf("adapter status=%s", response.AdapterStatus)
|
||||
}
|
||||
radar = response
|
||||
}
|
||||
var credentialResponse dto.DeviceResponse
|
||||
if err := service.UpdateCredentials(&dto.CredentialUpdateReq{ID: radar.ID, ONVIFUsername: "synthetic", ONVIFPassword: "synthetic", RTSPSameAsONVIF: true, Version: radar.Version}, &credentialResponse); err != ErrInvalidDevice {
|
||||
t.Fatalf("non-video credential update error=%v", err)
|
||||
}
|
||||
request := &dto.PageReq{}
|
||||
request.PageIndex, request.PageSize = 1, 20
|
||||
var list []dto.DeviceResponse
|
||||
var count int64
|
||||
if err := service.GetPage(request, &list, &count); err != nil || count != 20 || len(list) != 20 {
|
||||
t.Fatalf("count=%d items=%d err=%v", count, len(list), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
type PageReq struct {
|
||||
commonDto.Pagination `search:"-"`
|
||||
Keyword string `form:"keyword"`
|
||||
Modality string `form:"modality"`
|
||||
Status string `form:"status"`
|
||||
}
|
||||
|
||||
type CreateReq struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
CreateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type UpdateReq struct {
|
||||
ID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Version int64 `json:"version"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type DisableReq struct {
|
||||
ID string `json:"-"`
|
||||
Version int64 `json:"version"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type CredentialUpdateReq struct {
|
||||
ID string `json:"-"`
|
||||
ONVIFUsername string `json:"onvifUsername"`
|
||||
ONVIFPassword string `json:"onvifPassword"`
|
||||
RTSPSameAsONVIF bool `json:"rtspSameAsOnvif"`
|
||||
RTSPUsername string `json:"rtspUsername"`
|
||||
RTSPPassword string `json:"rtspPassword"`
|
||||
Version int64 `json:"version"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type DeviceResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Status string `json:"status"`
|
||||
AdapterStatus string `json:"adapterStatus"`
|
||||
ONVIFCredentialConfigured bool `json:"onvifCredentialConfigured"`
|
||||
RTSPCredentialConfigured bool `json:"rtspCredentialConfigured"`
|
||||
RTSPCredentialSameAsONVIF bool `json:"rtspCredentialSameAsOnvif"`
|
||||
RetryPending bool `json:"retryPending"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -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, `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetCapabilities xmlns="http://www.onvif.org/ver10/device/wsdl"><Category>All</Category></GetCapabilities></s:Body></s:Envelope>`)
|
||||
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, `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfiles xmlns="http://www.onvif.org/ver10/media/wsdl"/></s:Body></s:Envelope>`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles, err := parseProfiles(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range profiles {
|
||||
body := fmt.Sprintf(`<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl"><StreamSetup><Stream xmlns="http://www.onvif.org/ver10/schema">RTP-Unicast</Stream><Transport xmlns="http://www.onvif.org/ver10/schema"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>%s</ProfileToken></GetStreamUri></s:Body></s:Envelope>`, 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()
|
||||
}
|
||||
@@ -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, `<Envelope><Body><GetCapabilitiesResponse><Capabilities><Media><XAddr>http://unusable.invalid/onvif/media</XAddr></Media></Capabilities></GetCapabilitiesResponse></Body></Envelope>`)
|
||||
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, `<Envelope><Body><GetProfilesResponse><Profiles token="main"><Name>主码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>1920</Width><Height>1080</Height></Resolution></VideoEncoderConfiguration></Profiles></GetProfilesResponse></Body></Envelope>`)
|
||||
default:
|
||||
fmt.Fprint(w, `<Envelope><Body><GetStreamUriResponse><MediaUri><Uri>rtsp://unusable.invalid:8554/live</Uri></MediaUri></GetStreamUriResponse></Body></Envelope>`)
|
||||
}
|
||||
}))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 = `<?xml version="1.0"?><e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:dn="http://www.onvif.org/ver10/network/wsdl"><e:Header><w:MessageID>uuid:sense-controlled-discovery</w:MessageID><w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To><w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action></e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body></e:Envelope>`
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"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/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"
|
||||
)
|
||||
|
||||
type deviceCasbinRule struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V0 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V1 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V2 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V3 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V4 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V5 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
}
|
||||
|
||||
func (deviceCasbinRule) TableName() string { return "casbin_rule" }
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseDeviceLedger)
|
||||
}
|
||||
|
||||
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
|
||||
MenuName: "SenseDeviceManage", Title: "设备管理", Icon: "monitor", Path: "/sense/devices",
|
||||
MenuType: "C", Permission: "sense:device:list", ParentId: 0, Component: "/sense/device/index",
|
||||
Sort: 5, Visible: "0", IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buttons := make([]migrationModels.SysMenu, 0, 4)
|
||||
for index, definition := range []struct{ name, title, action, permission string }{
|
||||
{"SenseDeviceAdd", "新增设备", "POST", "sense:device:add"},
|
||||
{"SenseDeviceEdit", "编辑设备", "PUT", "sense:device:edit"},
|
||||
{"SenseDeviceDisable", "停用设备", "PUT", "sense:device:disable"},
|
||||
{"SenseDeviceCredential", "更新设备凭据", "PUT", "sense:device:credential"},
|
||||
} {
|
||||
button, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
|
||||
MenuName: definition.name, Title: definition.title, MenuType: "F", Action: definition.action,
|
||||
Permission: definition.permission, ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d", page.MenuId),
|
||||
Sort: index + 1, Visible: "1", IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buttons = append(buttons, button)
|
||||
}
|
||||
if err = attachDeviceRole(tx, "implementation_operator", append([]migrationModels.SysMenu{page}, buttons...)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = attachDeviceRole(tx, "site_admin", append([]migrationModels.SysMenu{page}, buttons...)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
|
||||
return err
|
||||
}
|
||||
readPolicies := [][2]string{{"/api/v1/devices", "GET"}, {"/api/v1/devices/:id", "GET"}}
|
||||
writePolicies := [][2]string{{"/api/v1/devices", "POST"}, {"/api/v1/devices/:id", "PUT"}, {"/api/v1/devices/:id/disable", "PUT"}, {"/api/v1/devices/:id/credentials", "PUT"}}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
policies := append([][2]string{}, readPolicies...)
|
||||
if role != "viewer" {
|
||||
policies = append(policies, writePolicies...)
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
|
||||
var menu migrationModels.SysMenu
|
||||
err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return menu, err
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
menu = desired
|
||||
if err = tx.Create(&menu).Error; err != nil {
|
||||
return menu, err
|
||||
}
|
||||
}
|
||||
paths := desired.Paths
|
||||
if desired.ParentId == 0 {
|
||||
paths = fmt.Sprintf("/0/%d", menu.MenuId)
|
||||
}
|
||||
err = tx.Model(&menu).Updates(map[string]any{
|
||||
"title": desired.Title, "icon": desired.Icon, "path": desired.Path, "paths": paths,
|
||||
"menu_type": desired.MenuType, "action": desired.Action, "permission": desired.Permission,
|
||||
"parent_id": desired.ParentId, "component": desired.Component, "sort": desired.Sort,
|
||||
"visible": desired.Visible, "is_frame": desired.IsFrame,
|
||||
}).Error
|
||||
return menu, err
|
||||
}
|
||||
|
||||
func attachDeviceRole(tx *gorm.DB, roleKey string, menus []migrationModels.SysMenu) error {
|
||||
var role migrationModels.SysRole
|
||||
if err := tx.Where("role_key = ?", roleKey).First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&role).Association("SysMenu").Append(menus)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -136,7 +136,8 @@ func redactAuditValue(value interface{}) {
|
||||
func isSensitiveAuditKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
||||
switch normalized {
|
||||
case "password", "oldpassword", "newpassword", "token", "refreshtoken", "secret", "code", "uuid":
|
||||
case "password", "oldpassword", "newpassword", "onvifusername", "onvifpassword", "rtspusername", "rtsppassword",
|
||||
"token", "refreshtoken", "secret", "code", "uuid":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -18,6 +18,18 @@ func TestSanitizeAuditJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAuditJSONHidesCameraCredentialFields(t *testing.T) {
|
||||
value := sanitizeAuditJSON(`{"onvifUsername":"camera-user","onvifPassword":"camera-password","rtspUsername":"stream-user","rtspPassword":"stream-password","name":"东门摄像机"}`)
|
||||
for _, forbidden := range []string{"camera-user", "camera-password", "stream-user", "stream-password"} {
|
||||
if strings.Contains(value, forbidden) {
|
||||
t.Fatalf("credential value leaked in audit JSON: %s", value)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(value, "东门摄像机") {
|
||||
t.Fatalf("non-sensitive device field was unexpectedly removed: %s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAuditJSONRejectsUnstructuredBodies(t *testing.T) {
|
||||
if got := sanitizeAuditJSON("password=secret-value"); strings.Contains(got, "secret-value") {
|
||||
t.Fatalf("unstructured body leaked: %s", got)
|
||||
|
||||
@@ -0,0 +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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }) }
|
||||
@@ -0,0 +1,25 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listDevices(query) {
|
||||
return request({ url: '/api/v1/devices', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getDevice(id) {
|
||||
return request({ url: `/api/v1/devices/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function addDevice(data) {
|
||||
return request({ url: '/api/v1/devices', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateDevice(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function disableDevice(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}/disable`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function updateDeviceCredentials(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}/credentials`, method: 'put', data })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div><h3>视频接入</h3><p>选择已登记的视频设备,发现或填写 ONVIF 地址,然后验证主、子码流。</p></div>
|
||||
<el-button v-permisaction="['sense:admission:discover']" type="primary" plain :loading="discovering" @click="handleDiscover">发现设备</el-button>
|
||||
</div>
|
||||
<el-alert title="发现只使用服务端配置的获准网卡;手工地址也只能访问获准网段。" type="info" :closable="false" show-icon />
|
||||
<el-form ref="probeFormRef" :model="form" :rules="rules" label-width="120px" class="probe-form">
|
||||
<el-form-item label="设备" prop="deviceId">
|
||||
<el-select v-model="form.deviceId" filterable placeholder="请选择已登记的视频设备" @change="selectDevice">
|
||||
<el-option v-for="device in devices" :key="device.id" :label="`${device.name} · ${device.location || '未填写位置'}`" :value="device.id" :disabled="device.status === 'disabled' || !device.onvifCredentialConfigured" />
|
||||
</el-select>
|
||||
<span class="field-hint">未配置凭据或已停用的设备不可探测。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="ONVIF 地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="例如:http://设备地址/onvif/device_service" />
|
||||
<span class="field-hint">地址中不能包含用户名或密码;凭据从设备管理安全读取。</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button v-permisaction="['sense:admission:probe']" type="primary" :loading="probing" @click="handleProbe">验证接入</el-button>
|
||||
<el-button @click="loadSaved">查看上次结果</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-divider />
|
||||
<el-empty v-if="!result" description="尚无接入验证结果" />
|
||||
<template v-else>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="设备状态"><el-tag :type="result.status === 'ready' ? 'success' : 'warning'">{{ statusLabel(result.status) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="检查时间">{{ parseTime(result.checkedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果说明" :span="2">{{ result.detail }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="result.profiles || []" border stripe class="profile-table">
|
||||
<el-table-column prop="name" label="Profile" min-width="130" />
|
||||
<el-table-column label="用途" width="90"><template #default="scope"><el-tag size="small">{{ scope.row.kind === 'main' ? '主码流' : scope.row.kind === 'sub' ? '子码流' : '其他' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="分辨率" width="110"><template #default="scope">{{ scope.row.width }} × {{ scope.row.height }}</template></el-table-column>
|
||||
<el-table-column prop="encoding" label="编码" width="90" />
|
||||
<el-table-column label="验证状态" width="130"><template #default="scope"><el-tag :type="scope.row.verificationStatus === 'ready' ? 'success' : 'danger'">{{ statusLabel(scope.row.verificationStatus) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="verificationDetail" label="说明" min-width="180" />
|
||||
</el-table>
|
||||
</template>
|
||||
</el-card>
|
||||
<el-dialog v-model="discoveryOpen" title="发现结果" width="720px">
|
||||
<el-empty v-if="!discovered.length" description="获准网卡内未发现设备" />
|
||||
<el-table v-else :data="discovered.map(address => ({ address }))" border>
|
||||
<el-table-column prop="address" label="ONVIF 地址" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100"><template #default="scope"><el-button type="primary" link @click="useAddress(scope.row.address)">使用</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listDevices } from '@/api/sense/device'
|
||||
import { discoverDevices, getAdmission, probeDevice } from '@/api/sense/admission'
|
||||
import { addressHasCredentials, buildProbePayload } from './admissionPayload'
|
||||
|
||||
const devices = ref([]); const result = ref(null); const discovered = ref([])
|
||||
const discovering = ref(false); const probing = ref(false); const discoveryOpen = ref(false); const probeFormRef = ref()
|
||||
const form = reactive({ deviceId: '', address: '', version: 0 })
|
||||
const rules = { deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }], address: [{ required: true, message: '请输入 ONVIF 地址', trigger: 'blur' }, { validator: (_r, value, done) => addressHasCredentials(value) ? done(new Error('地址中不能包含用户名或密码')) : done(), trigger: 'blur' }] }
|
||||
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function selectDevice(id) { const device = devices.value.find(item => item.id === id); form.version = device?.version || 0; result.value = null }
|
||||
async function loadDevices() { const response = await listDevices({ pageIndex: 1, pageSize: 100, modality: 'video' }); const payload = unwrap(response); devices.value = payload?.list || payload?.data || payload || [] }
|
||||
async function handleDiscover() { discovering.value = true; try { const response = await discoverDevices(); const payload = unwrap(response); discovered.value = payload?.addresses || []; discoveryOpen.value = true } catch (error) { ElMessage.error(error.message || '发现失败,请检查获准网卡配置') } finally { discovering.value = false } }
|
||||
function useAddress(address) { form.address = address; discoveryOpen.value = false }
|
||||
async function handleProbe() { const valid = await probeFormRef.value?.validate().catch(() => false); if (!valid) return; probing.value = true; try { const response = await probeDevice(form.deviceId, buildProbePayload(form)); result.value = unwrap(response); const device = devices.value.find(item => item.id === form.deviceId); if (device) { device.version += 1; form.version = device.version } ElMessage.success('接入验证完成') } catch (error) { ElMessage.error(error.message || '接入验证失败') } finally { probing.value = false } }
|
||||
async function loadSaved() { if (!form.deviceId) return ElMessage.warning('请先选择设备'); try { result.value = unwrap(await getAdmission(form.deviceId)) } catch (error) { ElMessage.warning(error.message || '尚无验证结果') } }
|
||||
function statusLabel(status) { return ({ ready: '可用', profile_failed: '部分码流失败', authentication_failed: '认证失败', target_not_allowed: '目标未获准', redirect_rejected: '重定向已拒绝', timeout: '响应超时', clock_skew: '设备时间异常', unreachable: '无法连接', failed: '验证失败' })[status] || status || '未知' }
|
||||
onMounted(loadDevices)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.probe-form{max-width:820px;margin-top:22px}.probe-form .el-select{width:100%}.field-hint{display:block;color:#909399;font-size:12px;line-height:20px}.profile-table{margin-top:18px}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
export function createDevicePayload(form) {
|
||||
return {
|
||||
name: form.name,
|
||||
location: form.location,
|
||||
modality: form.modality,
|
||||
capabilities: [...form.capabilities]
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDevicePayload(form) {
|
||||
return {
|
||||
name: form.name,
|
||||
location: form.location,
|
||||
capabilities: [...form.capabilities],
|
||||
version: form.version
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialPayload(form) {
|
||||
return {
|
||||
onvifUsername: form.onvifUsername,
|
||||
onvifPassword: form.onvifPassword,
|
||||
rtspSameAsOnvif: form.rtspSameAsOnvif,
|
||||
rtspUsername: form.rtspSameAsOnvif ? '' : form.rtspUsername,
|
||||
rtspPassword: form.rtspSameAsOnvif ? '' : form.rtspPassword,
|
||||
version: form.version
|
||||
}
|
||||
}
|
||||
|
||||
export function disableDevicePayload(version) {
|
||||
return { version }
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<el-form ref="queryForm" :model="queryParams" :inline="true" class="search-form">
|
||||
<el-form-item label="设备信息" prop="keyword">
|
||||
<el-input v-model="queryParams.keyword" placeholder="名称或安装位置" clearable size="small" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="modality">
|
||||
<el-select v-model="queryParams.modality" placeholder="全部类型" clearable size="small">
|
||||
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="全部状态" clearable size="small">
|
||||
<el-option label="待接入" value="pending" />
|
||||
<el-option label="已接入" value="active" />
|
||||
<el-option label="已停用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="small" :icon="Search" @click="handleQuery">搜索</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar mb8">
|
||||
<el-button v-permisaction="['sense:device:add']" type="primary" size="small" :icon="Plus" @click="handleAdd">新增设备</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="deviceList" border stripe>
|
||||
<el-table-column label="设备名称" min-width="150" prop="name" />
|
||||
<el-table-column label="安装位置" min-width="180" prop="location" show-overflow-tooltip />
|
||||
<el-table-column label="设备类型" width="110" align="center">
|
||||
<template #default="scope">{{ modalityLabel(scope.row.modality) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="能力" min-width="130">
|
||||
<template #default="scope">
|
||||
<el-tag v-for="capability in scope.row.capabilities" :key="capability" size="small" class="capability-tag">{{ modalityLabel(capability) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接入能力" width="130" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.adapterStatus === 'ready' ? 'success' : 'warning'">
|
||||
{{ scope.row.adapterStatus === 'ready' ? '可接入' : '适配器未就绪' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="凭据" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.onvifCredentialConfigured ? 'success' : 'info'">
|
||||
{{ scope.row.onvifCredentialConfigured ? '已配置' : '未配置' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'active' ? 'success' : scope.row.status === 'disabled' ? 'info' : 'warning'">{{ scope.row.status === 'active' ? '已接入' : scope.row.status === 'disabled' ? '已停用' : '待接入' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="80" align="center" prop="version" />
|
||||
<el-table-column label="更新时间" width="180" align="center">
|
||||
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" align="center" fixed="right" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:device:edit']" type="primary" link size="small" :icon="Edit" @click="handleUpdate(scope.row)">编辑</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-permisaction="['sense:device:credential']" type="primary" link size="small" :icon="Key" @click="handleCredential(scope.row)">更新凭据</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-if="scope.row.status !== 'disabled'" v-permisaction="['sense:device:disable']" type="danger" link size="small" @click="handleDisable(scope.row)">停用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:current-page="queryParams.pageIndex" v-model:page-size="queryParams.pageSize" :total="total" @pagination="getList" />
|
||||
|
||||
<el-dialog v-model="deviceOpen" :title="deviceTitle" width="620px" :close-on-click-modal="false">
|
||||
<el-form ref="deviceForm" :model="deviceForm" :rules="deviceRules" label-width="100px">
|
||||
<el-form-item label="设备名称" prop="name">
|
||||
<el-input v-model="deviceForm.name" maxlength="128" show-word-limit placeholder="例如:东门摄像机" />
|
||||
</el-form-item>
|
||||
<el-form-item label="安装位置" prop="location">
|
||||
<el-input v-model="deviceForm.location" maxlength="255" show-word-limit placeholder="例如:教学楼一楼东门" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="modality">
|
||||
<el-select v-model="deviceForm.modality" :disabled="Boolean(deviceForm.id)" @change="handleModalityChange">
|
||||
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<span v-if="deviceForm.modality !== 'video'" class="form-tip">该类型尚未提供接入适配器,将显示“适配器未就绪”。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备能力" prop="capabilities">
|
||||
<el-checkbox-group v-model="deviceForm.capabilities">
|
||||
<el-checkbox v-for="item in modalityOptions" :key="item.value" :label="item.value">{{ item.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitDevice">确定</el-button>
|
||||
<el-button @click="deviceOpen = false">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="credentialOpen" title="更新摄像头凭据" width="620px" :close-on-click-modal="false" @closed="resetCredential">
|
||||
<el-alert title="凭据只可写入,保存后不会回显。更新成功会请求后续接入流程重新验证。" type="warning" :closable="false" show-icon class="credential-alert" />
|
||||
<el-form ref="credentialForm" :model="credentialForm" :rules="credentialRules" label-width="120px" autocomplete="off">
|
||||
<el-form-item label="ONVIF 用户名" prop="onvifUsername">
|
||||
<el-input v-model="credentialForm.onvifUsername" maxlength="255" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="ONVIF 密码" prop="onvifPassword">
|
||||
<el-input v-model="credentialForm.onvifPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="RTSP 凭据">
|
||||
<el-checkbox v-model="credentialForm.rtspSameAsOnvif">与 ONVIF 相同</el-checkbox>
|
||||
</el-form-item>
|
||||
<template v-if="!credentialForm.rtspSameAsOnvif">
|
||||
<el-form-item label="RTSP 用户名" prop="rtspUsername">
|
||||
<el-input v-model="credentialForm.rtspUsername" maxlength="255" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="RTSP 密码" prop="rtspPassword">
|
||||
<el-input v-model="credentialForm.rtspPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitCredential">安全保存</el-button>
|
||||
<el-button @click="credentialOpen = false">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
addDevice,
|
||||
disableDevice,
|
||||
getDevice,
|
||||
listDevices,
|
||||
updateDevice,
|
||||
updateDeviceCredentials
|
||||
} from '@/api/sense/device'
|
||||
import {
|
||||
createDevicePayload,
|
||||
credentialPayload,
|
||||
disableDevicePayload,
|
||||
updateDevicePayload
|
||||
} from './devicePayload'
|
||||
|
||||
export default {
|
||||
name: 'SenseDeviceManage',
|
||||
setup() {
|
||||
return { Edit, Key, Plus, Refresh, Search }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
total: 0,
|
||||
deviceList: [],
|
||||
deviceOpen: false,
|
||||
credentialOpen: false,
|
||||
deviceTitle: '',
|
||||
queryParams: { pageIndex: 1, pageSize: 10, keyword: '', modality: '', status: '' },
|
||||
modalityOptions: [
|
||||
{ value: 'video', label: '视频设备' },
|
||||
{ value: 'radar', label: '雷达' },
|
||||
{ value: 'contact', label: '门磁' },
|
||||
{ value: 'button', label: '按钮' },
|
||||
{ value: 'wearable', label: '穿戴设备' },
|
||||
{ value: 'other', label: '其他' }
|
||||
],
|
||||
deviceForm: {},
|
||||
credentialForm: {},
|
||||
deviceRules: {
|
||||
name: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
|
||||
modality: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||
capabilities: [{ type: 'array', required: true, min: 1, message: '至少选择一项设备能力', trigger: 'change' }]
|
||||
},
|
||||
credentialRules: {
|
||||
onvifUsername: [{ required: true, message: 'ONVIF 用户名不能为空', trigger: 'blur' }],
|
||||
onvifPassword: [{ required: true, message: 'ONVIF 密码不能为空', trigger: 'blur' }],
|
||||
rtspUsername: [{ required: true, message: 'RTSP 用户名不能为空', trigger: 'blur' }],
|
||||
rtspPassword: [{ required: true, message: 'RTSP 密码不能为空', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.resetDevice()
|
||||
this.resetCredential()
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true
|
||||
listDevices(this.queryParams).then(response => {
|
||||
this.deviceList = response.data.list
|
||||
this.total = response.data.count
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
modalityLabel(value) {
|
||||
return this.modalityOptions.find(item => item.value === value)?.label || value
|
||||
},
|
||||
resetDevice() {
|
||||
this.deviceForm = { id: undefined, name: '', location: '', modality: 'video', capabilities: ['video'], version: undefined }
|
||||
this.$nextTick(() => this.resetForm('deviceForm'))
|
||||
},
|
||||
resetCredential() {
|
||||
this.credentialForm = {
|
||||
id: undefined,
|
||||
onvifUsername: '',
|
||||
onvifPassword: '',
|
||||
rtspSameAsOnvif: true,
|
||||
rtspUsername: '',
|
||||
rtspPassword: '',
|
||||
version: undefined
|
||||
}
|
||||
this.$nextTick(() => this.resetForm('credentialForm'))
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.resetForm('queryForm')
|
||||
this.handleQuery()
|
||||
},
|
||||
handleAdd() {
|
||||
this.resetDevice()
|
||||
this.deviceTitle = '新增设备'
|
||||
this.deviceOpen = true
|
||||
},
|
||||
handleUpdate(row) {
|
||||
this.resetDevice()
|
||||
getDevice(row.id).then(response => {
|
||||
this.deviceForm = { ...response.data, capabilities: [...response.data.capabilities] }
|
||||
this.deviceTitle = '编辑设备'
|
||||
this.deviceOpen = true
|
||||
})
|
||||
},
|
||||
handleModalityChange(value) {
|
||||
this.deviceForm.capabilities = [value]
|
||||
},
|
||||
submitDevice() {
|
||||
this.$refs.deviceForm.validate(valid => {
|
||||
if (!valid) return
|
||||
const request = this.deviceForm.id
|
||||
? updateDevice(this.deviceForm.id, updateDevicePayload(this.deviceForm))
|
||||
: addDevice(createDevicePayload(this.deviceForm))
|
||||
request.then(response => {
|
||||
this.msgSuccess(response.msg)
|
||||
this.deviceOpen = false
|
||||
this.getList()
|
||||
})
|
||||
})
|
||||
},
|
||||
handleCredential(row) {
|
||||
this.resetCredential()
|
||||
this.credentialForm.id = row.id
|
||||
this.credentialForm.version = row.version
|
||||
this.credentialOpen = true
|
||||
},
|
||||
submitCredential() {
|
||||
this.$refs.credentialForm.validate(valid => {
|
||||
if (!valid) return
|
||||
updateDeviceCredentials(this.credentialForm.id, credentialPayload(this.credentialForm)).then(response => {
|
||||
this.msgSuccess(response.msg)
|
||||
this.credentialOpen = false
|
||||
this.getList()
|
||||
})
|
||||
})
|
||||
},
|
||||
handleDisable(row) {
|
||||
this.$confirm(`停用“${row.name}”后,后续接入流程不会继续使用该设备。是否继续?`, '停用设备', {
|
||||
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(() => disableDevice(row.id, disableDevicePayload(row.version))).then(response => {
|
||||
this.msgSuccess(response.msg)
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.capability-tag {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.form-tip {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.credential-alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
createDevicePayload,
|
||||
credentialPayload,
|
||||
disableDevicePayload,
|
||||
updateDevicePayload
|
||||
} from '@/views/sense/device/devicePayload'
|
||||
|
||||
describe('Sense device payload allowlists', () => {
|
||||
const form = {
|
||||
id: 'read-only-id',
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
modality: 'video',
|
||||
capabilities: ['video'],
|
||||
version: 9,
|
||||
adapterStatus: 'ready',
|
||||
unexpected: 'must-not-be-sent'
|
||||
}
|
||||
|
||||
test('create keeps Chinese fields and only writable create fields', () => {
|
||||
expect(createDevicePayload(form)).toEqual({
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
modality: 'video',
|
||||
capabilities: ['video']
|
||||
})
|
||||
})
|
||||
|
||||
test('update excludes id, modality and server state while carrying version', () => {
|
||||
expect(updateDevicePayload(form)).toEqual({
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
capabilities: ['video'],
|
||||
version: 9
|
||||
})
|
||||
})
|
||||
|
||||
test('credential payload does not send unused RTSP fields', () => {
|
||||
expect(credentialPayload({
|
||||
onvifUsername: 'synthetic-user',
|
||||
onvifPassword: 'synthetic-password',
|
||||
rtspSameAsOnvif: true,
|
||||
rtspUsername: 'must-not-be-sent',
|
||||
rtspPassword: 'must-not-be-sent',
|
||||
version: 9
|
||||
})).toEqual({
|
||||
onvifUsername: 'synthetic-user',
|
||||
onvifPassword: 'synthetic-password',
|
||||
rtspSameAsOnvif: true,
|
||||
rtspUsername: '',
|
||||
rtspPassword: '',
|
||||
version: 9
|
||||
})
|
||||
expect(disableDevicePayload(9)).toEqual({ version: 9 })
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: eb1174040657f2064e26b6448ca92e7a93666423
|
||||
synchronized_at: 2026-08-14T04:09:07Z
|
||||
wiki_revision: 915b5ca9a9a097470cb67a80f73045d16d3d57be
|
||||
synchronized_at: 2026-08-14T08:53:04Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -88,6 +88,14 @@ Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入
|
||||
工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。
|
||||
|
||||
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。
|
||||
|
||||
工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。
|
||||
|
||||
设备写入采用版本号乐观并发控制;视频设备适配器状态为可接入,雷达、门磁、按钮、穿戴和其他类型明确显示“适配器未就绪”,不得伪装成已接入。`admin`、`implementation_operator`、`site_admin` 可维护设备,`viewer` 只读;停用替代物理删除。
|
||||
|
||||
工单 #66 在 Sense/server/app/sense/onvif/、rtsp/ 与 admission/ 建立视频接入边界:WS-Discovery 只能绑定 SENSE_ONVIF_DISCOVERY_IP 指定的本机网卡,所有 ONVIF、Media XAddr 与 RTSP Stream URI 都必须落在 SENSE_ONVIF_ALLOWED_CIDRS 明确授权的网段。HTTP 客户端禁止代理和重定向,并在每次连接时重新解析、校验和固定目标 IP,防止 DNS 重绑定;URL 用户信息及敏感查询参数被拒绝。
|
||||
|
||||
ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Stream URI 持久化到 PostgreSQL。接入失败会记录可行动状态但保留最后一次已验证 Profile;成功接入清除凭据更新触发的重试标记。前端继续复用 GoAdmin 动态菜单、权限链、BasicLayout 和 Element Plus 表单、Dialog、Table、Tag。
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
<!-- sense-mvp:start -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 53a9c59534560841cd65fec74492ac3a55ffebe1
|
||||
synchronized_at: 2026-08-14T04:09:11Z
|
||||
wiki_revision: d1471acadb4759b80e9bc20e5cec18bd21ba65cd
|
||||
synchronized_at: 2026-08-14T08:53:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -94,3 +94,24 @@ synchronized_at: 2026-08-14T04:09:11Z
|
||||
## 旧实现迁移边界
|
||||
|
||||
旧 Sense 中已验证的中文设备字段、请求白名单、凭据隔离和媒体链路行为可作为迁移验收用例;不得把旧自研基础框架直接复制到新 `dev`。
|
||||
|
||||
<!-- sense-device-ledger:start -->
|
||||
## Sense 设备台账与凭据规则
|
||||
|
||||
- Device 是 Sense 内的设备根实体,类型包括视频、雷达、门磁、按钮、穿戴和其他;16 路仍只是默认交付配额,分页和台账容量不得硬编码为 16。
|
||||
- 当前仅视频设备具备可用接入适配器;其余类型必须显示“适配器未就绪”,不允许通过虚假成功状态掩盖能力缺口。
|
||||
- 设备台账支持列表、新建、编辑和停用;停用保留审计与后续关联,不提供物理删除。编辑、停用和凭据更新均使用版本号检测并发冲突。
|
||||
- 摄像头 ONVIF 与 RTSP 凭据按设备和用途分离,以 AES-256-GCM 密文保存;加密密钥只从外部环境 `SENSE_CREDENTIAL_KEY` 注入,不写入数据库、仓库、工单、Wiki 或日志。
|
||||
- 凭据只写不可读:HTTP 和页面仅显示“已配置/未配置”,不得回填用户名、密码或密文;更新凭据后只记录状态并请求后续接入流程重试。
|
||||
- `admin`、`implementation_operator`、`site_admin` 可维护设备与凭据,`viewer` 仅可查看设备台账。
|
||||
<!-- sense-device-ledger:end -->
|
||||
|
||||
<!-- sense-admission:start -->
|
||||
## Sense 视频接入规则
|
||||
|
||||
- “获准网卡”和“获准目标网段”都是部署人员显式配置的授权边界;私网地址不自动代表已授权。未配置发现网卡时不发送 WS-Discovery,手工地址也必须通过目标 CIDR 检查。
|
||||
- ONVIF 设备地址、Media XAddr 和 RTSP Stream URI 禁止 URL 用户信息、敏感认证查询参数、HTTP 重定向和超出授权网段的目标。摄像机返回不可用主机名时,只能归一化为已验证设备主机并重新执行授权检查。
|
||||
- ONVIF 支持 Basic、MD5 Digest 和 SHA-256 Digest 的 auth;不支持的算法或 qop 必须拒绝,不静默降级。
|
||||
- Profile 保存 token、名称、分辨率、编码、用途、无凭据 Stream URI 和逐 Profile 验证状态;主码流默认取分辨率最高项,子码流取最低项。
|
||||
- 认证失败、超时、时间异常、目标未授权和重定向拒绝必须给出不同状态。失败重探不得删除最后一次已验证 Profile;凭据更新后可重新探测。
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 9007f0ff45178b7535c2a38b6e38d44628c3210d
|
||||
synchronized_at: 2026-08-14T04:09:15Z
|
||||
wiki_revision: c8afa08e1e189a9c2779da1cbd3ed75124fd7eae
|
||||
synchronized_at: 2026-08-14T08:53:20Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -151,6 +151,22 @@ Remove-Variable bootstrapToken, bootstrapBody
|
||||
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
|
||||
|
||||
详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`。
|
||||
|
||||
设备台账启用凭据写入前,还必须在服务进程环境提供独立随机密钥;示例文件 `Sense/server/config/credential.env.example` 只保留空值:
|
||||
|
||||
```powershell
|
||||
# 生成一次随机 32 字节密钥并以 Base64 形式注入当前进程;不要打印或写入仓库
|
||||
$keyBytes = New-Object byte[] 32
|
||||
[System.Security.Cryptography.RandomNumberGenerator]::Fill($keyBytes)
|
||||
$env:SENSE_CREDENTIAL_KEY = [Convert]::ToBase64String($keyBytes)
|
||||
[Array]::Clear($keyBytes, 0, $keyBytes.Length)
|
||||
```
|
||||
|
||||
缺少或格式错误的密钥时,普通设备台账仍可读写,但凭据更新返回服务不可用且不得产生部分写入。设备回归至少覆盖:中文名称与位置、未知 JSON 字段拒绝、版本冲突返回 409、非视频设备显示适配器未就绪、viewer 只读、凭据响应/操作日志不含明文,以及 PostgreSQL 迁移重复执行不增加菜单或权限记录。
|
||||
|
||||
视频接入还需在仓库外配置 SENSE_ONVIF_DISCOVERY_IP(获准的本机网卡 IP)和 SENSE_ONVIF_ALLOWED_CIDRS(逗号分隔的获准摄像头网段)。不要使用 0.0.0.0/0 代替授权清单。
|
||||
|
||||
协议回归位于 app/sense/onvif、app/sense/rtsp、app/sense/admission;隔离 PostgreSQL 重启恢复测试通过 SENSE_ADMISSION_TEST_DATABASE_URL 显式启用。验证至少覆盖 Digest/Basic、无配置发现提示、URL 凭据和敏感查询拒绝、目标网段、重定向、Media/Stream 主机归一化、主子码流、失败重探保留已验证 Profile,以及 viewer 只读权限。
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: 2be5d803b954df64e31085bcf097ee27f8611f9d
|
||||
synchronized_at: 2026-08-14T01:17:28Z
|
||||
wiki_revision: ae80dc2d34341ff3cddfff93f51373adb3bd0664
|
||||
synchronized_at: 2026-08-14T08:53:30Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -60,3 +60,17 @@ synchronized_at: 2026-08-14T01:17:28Z
|
||||
|---|---|
|
||||
| 在 `dev` 找不到 Sense/Bell 可运行代码 | 这是重建空基线的预期状态;旧实现位于 `explore`,新代码必须由 GoAdmin 源码派生工单建立。 |
|
||||
| 新骨架只有相似页面、没有 GoAdmin 启动链或权限模块 | 不符合二次开发门禁;停止验收,对照 `goadmin-baseline.json`、上游源码和 go-admin-doc 重新实施。 |
|
||||
|
||||
<!-- sense-admission:start -->
|
||||
## Sense 视频接入排错
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
|---|---|
|
||||
| 未配置获准的发现网卡 | 在服务进程环境设置本机实际网卡 IP SENSE_ONVIF_DISCOVERY_IP;不要填写摄像机 IP。 |
|
||||
| 配置的发现地址不是本机网卡 | 网卡地址已变化或填写错误;用 Get-NetIPAddress 核对后重启服务。 |
|
||||
| 目标地址不在获准网段内 | 核对摄像机实际地址与 SENSE_ONVIF_ALLOWED_CIDRS;只追加已审批的最小 CIDR,不使用全网放行。 |
|
||||
| 认证失败 | 在设备管理重新填写 ONVIF/RTSP 凭据,再返回视频接入重新验证;页面不会回显旧凭据。 |
|
||||
| 设备时间异常 | 在摄像机管理页或受控 NTP 环境校时后重新探测;Sense 不自动修改设备时间。 |
|
||||
| 部分码流失败 | 查看逐 Profile 状态、设备 RTSP 权限和端口;最后一次已验证 Profile 会保留。 |
|
||||
| 重定向已拒绝 | ONVIF 服务返回了 3xx;修正为摄像机最终服务地址,不允许 Sense 跟随到未知目标。 |
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
+18
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Documentation-Guide
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
|
||||
wiki_revision: c754ebd705c55329a46da12e1860ffd7d137f99a
|
||||
synchronized_at: 2026-08-14T04:09:55Z
|
||||
wiki_revision: 07c8711ff4d75da5e4acbd61d029d27f01009747
|
||||
synchronized_at: 2026-08-14T08:54:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 交付文档指南
|
||||
@@ -104,3 +104,19 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任
|
||||
- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。
|
||||
- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。
|
||||
<!-- sense-windows-package:end -->
|
||||
|
||||
<!-- sense-device-ledger:start -->
|
||||
### Sense 设备台账交付说明
|
||||
|
||||
设备管理已开放列表、新建、编辑、停用和摄像头凭据更新。系统管理员、实施/运维和站点管理员可以维护;只读用户只能查看。现场人员应使用中文设备名称和安装位置,类型选择“视频”时才可配置 ONVIF/RTSP 凭据;其他类型会明确提示适配器尚未就绪。
|
||||
|
||||
凭据窗口每次均为空,不会回显已保存用户名或密码;“已配置”标签只表示服务器保存了密文。部署人员必须在仓库外为服务进程配置 Base64 编码的随机 32 字节 `SENSE_CREDENTIAL_KEY`,丢失或更换该密钥会使旧凭据不可用,因此应纳入受控秘密备份。停用设备不会物理删除台账。
|
||||
<!-- sense-device-ledger:end -->
|
||||
|
||||
<!-- sense-admission:start -->
|
||||
### Sense 视频接入交付说明
|
||||
|
||||
部署人员必须先确认获准摄像头网段,再把本机对应网卡 IP 配置为 SENSE_ONVIF_DISCOVERY_IP,把获准网段配置为逗号分隔的 SENSE_ONVIF_ALLOWED_CIDRS。不得为了省事填写全网段。现场人员在“视频接入”选择已登记且已配置凭据的视频设备,可使用发现结果或手工填写不含账号密码的 ONVIF 地址。
|
||||
|
||||
验证结果区分可用、部分码流失败、认证失败、目标未获准、重定向拒绝、响应超时、设备时间异常和无法连接,并显示主/子码流及逐 Profile 状态。失败重试不会删除上次已验证 Profile;修改凭据后应重新验证。真实摄像机兼容性、网络 ACL 和设备校时仍需在客户授权环境完成。
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-65-Sense设备台账与凭据边界
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-65-Sense%E8%AE%BE%E5%A4%87%E5%8F%B0%E8%B4%A6%E4%B8%8E%E5%87%AD%E6%8D%AE%E8%BE%B9%E7%95%8C.-
|
||||
wiki_revision: 9864d7fece44ca2e88ee8c3661101e3d6f74b030
|
||||
synchronized_at: 2026-08-14T08:24:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 65 Sense设备台账与凭据边界
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-14
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/65
|
||||
- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/83
|
||||
- 主项目 / 主 Agent:Sense / Sense Agent
|
||||
- 写路径:仅 `Sense/` 与本任务对应 Wiki、docs 镜像
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 #61 冻结 GoAdmin 产品骨架和 #64 独立身份能力上,重建设备台账、角色权限和摄像头凭据边界。界面服务网管、实施人员及非技术现场人员,通用外壳和组件复用 go-admin-ui;Brain、Bell 不参与且不作为验收依赖。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 新增视频、雷达、门磁、按钮、穿戴和其他类型设备台账,提供列表、新建、编辑和停用;停用替代物理删除。
|
||||
- 编辑、停用、凭据更新使用版本号乐观并发控制,过期写入返回 409;分页上限为每页 100,不把默认 16 路配额写成容量上限。
|
||||
- 当前视频类型标记适配器可接入,其他类型明确显示“适配器未就绪”。
|
||||
- ONVIF/RTSP 凭据按设备和用途分离,以外部 `SENSE_CREDENTIAL_KEY` 驱动 AES-256-GCM 加密;HTTP 和页面只返回配置状态,不提供读取凭据或密文接口。
|
||||
- 复用 GoAdmin 的 JWT、Casbin、操作审计、动态菜单、页面容器、Element Plus 表单/表格/分页/Dialog/Tag、权限按钮和 Axios 封装。
|
||||
- admin、implementation_operator、site_admin 可维护设备与凭据,viewer 只读;未知 JSON 字段拒绝,凭据字段加入日志脱敏。
|
||||
- 与建单方案的路径差异已在工单记录:按当前 GoAdmin 目录使用 `cmd/migrate/migration/version/` 和 `app/admin/router/sense_device.go`,没有修改共享路径。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/credential/`:凭据模型、AES-256-GCM vault 和作用域测试。
|
||||
- `Sense/server/app/sense/device/`:设备模型、白名单 DTO、服务、API 与测试。
|
||||
- `Sense/server/app/admin/router/sense_device.go`:注册受 JWT、Casbin 和权限动作保护的设备路由。
|
||||
- `Sense/server/cmd/migrate/migration/version/2026081414000_device.go`:设备表、凭据表、菜单、角色关联和 Casbin 权限。
|
||||
- `Sense/server/common/middleware/logger.go`:摄像头凭据字段脱敏。
|
||||
- `Sense/ui/src/views/sense/device/`、`Sense/ui/src/api/sense/device.js`:设备管理页面、请求白名单和接口。
|
||||
- `Sense/README.md`、Wiki 架构/业务规则/本地验证/交付指南:配置、安全边界和操作说明。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 设备列表、新建、编辑、停用与中文字段 | 自动化及 PostgreSQL HTTP 集成通过 |
|
||||
| 版本冲突和严格请求白名单 | 过期写入 409、未知字段 400 |
|
||||
| 多类型与非视频适配器状态 | 通过;20 条非视频分页验证未受 16 路配额限制 |
|
||||
| 凭据分离加密、只写不可读、审计脱敏 | 通过;两种 purpose、密文最短 138 字节、审计明文匹配 0 |
|
||||
| 角色权限 | admin/operator/site 可维护,viewer 读取 200、写入 403 |
|
||||
| GoAdmin/go-admin-ui 复用与无多余入口 | 通过代码与路由检查;未新增通用 UI 组件 |
|
||||
| 文档 | Wiki 已更新、docs 镜像检查通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`:通过。
|
||||
- `go vet ./...`:通过。
|
||||
- `go build ./...`:通过。
|
||||
- `corepack pnpm@9.15.1 install --frozen-lockfile`:通过。
|
||||
- `corepack pnpm@9.15.1 lint`:0 错误,30 条继承的上游 warning。
|
||||
- `corepack pnpm@9.15.1 test:unit`:8 个 suite、32 项测试通过。
|
||||
- `corepack pnpm@9.15.1 build:prod`:通过,6 条继承的构建 warning。
|
||||
- PostgreSQL 17 迁移及重复迁移:通过,菜单和权限记录未重复。
|
||||
- HTTP 集成:bootstrap 200、未知字段 400、创建 200、更新 200、过期写入 409、凭据更新 200、viewer 读取 200、viewer 写入 403。
|
||||
- 数据库证据:设备 3 条;凭据 2 条、2 种 purpose;凭据审计 1 条;审计测试明文匹配 0。
|
||||
- `python dev_scripts/sync_wiki_docs.py --check`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- **未验证部分**:真实摄像头接入、ONVIF/RTSP 连通和目标客户浏览器属于后续 #66 及现场验收;本工单不伪造其结果。
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 2026-08-14:用户明确回复“#65 验收通过”。
|
||||
- 验收后按工作流将 PR #83 合入 `dev`,不合入 `main`;同步关闭工单并更新父工单索引。
|
||||
|
||||
## 回退与遗留
|
||||
|
||||
回退 PR #83 的两个提交可移除本次入口;数据库表与权限记录保留,避免破坏审计或凭据数据。后续 #66 才消费本工单的凭据边界并实现真实 ONVIF/RTSP 接入。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `be091f0` feat: 重建设备台账与凭据边界 (#65)
|
||||
- `d31c35f` docs: 记录 Sense 设备台账与凭据边界 (#65)
|
||||
@@ -0,0 +1,67 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-66-Sense视频接入与Profile
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-66-Sense%E8%A7%86%E9%A2%91%E6%8E%A5%E5%85%A5%E4%B8%8EProfile.-
|
||||
wiki_revision: 322feb232fc03c3a9ba22f65504cf3e151fb0e57
|
||||
synchronized_at: 2026-08-14T09:15:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 66 Sense视频接入与Profile
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-14
|
||||
- 验收:用户于 2026-08-14 明确验收通过
|
||||
- 工单:https://git.ilapage.cn/ila/yovision/issues/66
|
||||
- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/84
|
||||
- 主项目:Sense
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 #65 设备与凭据边界上重建获准网卡 ONVIF 发现、手工接入、认证、RTSP 验证和 Profile 持久化。Brain、Bell 不启动时可独立验收。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- WS-Discovery 只绑定 SENSE_ONVIF_DISCOVERY_IP 指定的本机地址;无配置或非本机地址给出明确提示。
|
||||
- ONVIF、Media XAddr 和 RTSP Stream URI 均受 SENSE_ONVIF_ALLOWED_CIDRS 限制;每次连接重新解析并固定目标 IP,禁用代理和重定向,拒绝 URL 用户信息及敏感查询参数。
|
||||
- ONVIF 支持 Basic、MD5/SHA-256 Digest auth;不支持的算法或 qop 拒绝。
|
||||
- 内部读取 #65 的 ONVIF/RTSP 分用途密文,HTTP 不提供凭据读取。
|
||||
- Profile、主/子码流、无凭据 Stream URI 和逐项验证状态持久化到 PostgreSQL;失败重探保留最后一次已验证 Profile。
|
||||
- 复用 GoAdmin JWT/Casbin/操作审计、迁移和 go-admin-ui BasicLayout、Element Plus Form/Dialog/Table/Tag、动态菜单与权限按钮。
|
||||
- implementation_operator、site_admin 可发现和探测,viewer 只读保存结果。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 标准 | 结果 |
|
||||
|---|---|
|
||||
| 未配置获准网卡不扫描 | 单元测试及错误映射通过 |
|
||||
| Digest/Basic 与 RTSP 验证 | 合成协议服务通过 |
|
||||
| ONVIF/RTSP 分离凭据且不回显 | 内部端口与严格 DTO 通过 |
|
||||
| SSRF/重定向/凭据泄漏防护 | CIDR、DNS、URL、重定向测试通过 |
|
||||
| Profile 重启恢复 | PostgreSQL 17 连接重开测试通过 |
|
||||
| 认证失败、时间异常、重探 | 状态分类和失败保留 Profile 测试通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- go test ./...:通过。
|
||||
- go vet ./...:通过。
|
||||
- go build ./...:通过。
|
||||
- 前端 lint:0 error,31 条上游继承 warning。
|
||||
- 前端单测:9 suites、34 tests 通过。
|
||||
- 前端生产构建:通过,6 条继承 warning。
|
||||
- PostgreSQL 17:迁移与重复迁移通过;migration=1、tables=2、menus=3、policies=7。
|
||||
- SENSE_ADMISSION_TEST_DATABASE_URL 隔离测试:写入 Profile、重开连接、读取主子码流通过。
|
||||
- Wiki 镜像检查:通过。
|
||||
- 未验证部分:未连接客户或实验室真实摄像机;真实厂商 Digest/RTSP 兼容、网络 ACL、设备校时和目标浏览器留待授权现场验收,不声称已通过。
|
||||
|
||||
## 回退
|
||||
|
||||
回退 PR #84 的实现和文档提交可移除入口;已产生的接入表保留以避免破坏追溯数据。失败探测本身不删除最后一次已验证 Profile。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- 2bb1614 feat: 重建 ONVIF 与 RTSP 视频接入 (#66)
|
||||
- 26e2e63 docs: 记录 Sense 视频接入安全边界 (#66)
|
||||
- d74e1ae docs: 归档工单 #66 待验收证据
|
||||
@@ -107,6 +107,14 @@
|
||||
{
|
||||
"page": "Task-64-Sense登录RBAC与审计",
|
||||
"path": "docs/task/64-Sense登录RBAC与审计.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-65-Sense设备台账与凭据边界",
|
||||
"path": "docs/task/65-Sense设备台账与凭据边界.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-66-Sense视频接入与Profile",
|
||||
"path": "docs/task/66-Sense视频接入与Profile.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user