diff --git a/Sense/README.md b/Sense/README.md index 7897949..afd911b 100644 --- a/Sense/README.md +++ b/Sense/README.md @@ -40,4 +40,6 @@ 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 会拒绝凭据写入,不会降级为明文存储。 + +仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。 diff --git a/Sense/server/app/admin/router/sense_device.go b/Sense/server/app/admin/router/sense_device.go new file mode 100644 index 0000000..d8987b6 --- /dev/null +++ b/Sense/server/app/admin/router/sense_device.go @@ -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) + } +} diff --git a/Sense/server/app/sense/credential/vault.go b/Sense/server/app/sense/credential/vault.go new file mode 100644 index 0000000..55ed8df --- /dev/null +++ b/Sense/server/app/sense/credential/vault.go @@ -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) +} diff --git a/Sense/server/app/sense/credential/vault_test.go b/Sense/server/app/sense/credential/vault_test.go new file mode 100644 index 0000000..57ce627 --- /dev/null +++ b/Sense/server/app/sense/credential/vault_test.go @@ -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") + } +} diff --git a/Sense/server/app/sense/device/apis/device.go b/Sense/server/app/sense/device/apis/device.go new file mode 100644 index 0000000..da1d995 --- /dev/null +++ b/Sense/server/app/sense/device/apis/device.go @@ -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 +} diff --git a/Sense/server/app/sense/device/apis/device_test.go b/Sense/server/app/sense/device/apis/device_test.go new file mode 100644 index 0000000..2c38d69 --- /dev/null +++ b/Sense/server/app/sense/device/apis/device_test.go @@ -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") + } +} diff --git a/Sense/server/app/sense/device/models/device.go b/Sense/server/app/sense/device/models/device.go new file mode 100644 index 0000000..e9d52aa --- /dev/null +++ b/Sense/server/app/sense/device/models/device.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + common "git.ilapage.cn/ila/yovision/Sense/server/common/models" +) + +const ( + ModalityVideo = "video" + StatusPending = "pending" + StatusDisabled = "disabled" + AdapterReady = "ready" + 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" } diff --git a/Sense/server/app/sense/device/service/device.go b/Sense/server/app/sense/device/service/device.go new file mode 100644 index 0000000..579e2fd --- /dev/null +++ b/Sense/server/app/sense/device/service/device.go @@ -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 +} diff --git a/Sense/server/app/sense/device/service/device_test.go b/Sense/server/app/sense/device/service/device_test.go new file mode 100644 index 0000000..3e6d44a --- /dev/null +++ b/Sense/server/app/sense/device/service/device_test.go @@ -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) + } +} diff --git a/Sense/server/app/sense/device/service/dto/device.go b/Sense/server/app/sense/device/service/dto/device.go new file mode 100644 index 0000000..4b51090 --- /dev/null +++ b/Sense/server/app/sense/device/service/dto/device.go @@ -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"` +} diff --git a/Sense/server/cmd/migrate/migration/version/2026081414000_device.go b/Sense/server/cmd/migrate/migration/version/2026081414000_device.go new file mode 100644 index 0000000..7eae8e8 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026081414000_device.go @@ -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) +} diff --git a/Sense/server/common/middleware/logger.go b/Sense/server/common/middleware/logger.go index 92c9195..fc035ff 100644 --- a/Sense/server/common/middleware/logger.go +++ b/Sense/server/common/middleware/logger.go @@ -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 diff --git a/Sense/server/common/middleware/logger_test.go b/Sense/server/common/middleware/logger_test.go index 3a1eb38..d194684 100644 --- a/Sense/server/common/middleware/logger_test.go +++ b/Sense/server/common/middleware/logger_test.go @@ -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) diff --git a/Sense/server/config/credential.env.example b/Sense/server/config/credential.env.example new file mode 100644 index 0000000..3825f79 --- /dev/null +++ b/Sense/server/config/credential.env.example @@ -0,0 +1,3 @@ +# Required only when creating or updating camera credentials. +# Set outside the repository to a Base64-encoded random 32-byte key. +SENSE_CREDENTIAL_KEY= diff --git a/Sense/ui/src/api/sense/device.js b/Sense/ui/src/api/sense/device.js new file mode 100644 index 0000000..51e1826 --- /dev/null +++ b/Sense/ui/src/api/sense/device.js @@ -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 }) +} diff --git a/Sense/ui/src/views/sense/device/devicePayload.js b/Sense/ui/src/views/sense/device/devicePayload.js new file mode 100644 index 0000000..3e6ab58 --- /dev/null +++ b/Sense/ui/src/views/sense/device/devicePayload.js @@ -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 } +} diff --git a/Sense/ui/src/views/sense/device/index.vue b/Sense/ui/src/views/sense/device/index.vue new file mode 100644 index 0000000..871bed4 --- /dev/null +++ b/Sense/ui/src/views/sense/device/index.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/Sense/ui/tests/unit/sense/devicePayload.spec.js b/Sense/ui/tests/unit/sense/devicePayload.spec.js new file mode 100644 index 0000000..c0325fc --- /dev/null +++ b/Sense/ui/tests/unit/sense/devicePayload.spec.js @@ -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 }) + }) +})