From 38824dbc11a63964c5b9296ae4c68e62b34fa04b Mon Sep 17 00:00:00 2001 From: naiba Date: Sat, 15 Aug 2026 05:00:28 +0000 Subject: [PATCH] fix(security): restrict service monitors to probe tasks --- cmd/dashboard/controller/oauth2.go | 7 +- .../controller/permission_matrix_test.go | 4 +- cmd/dashboard/controller/service.go | 6 ++ .../controller/service_type_security_test.go | 91 +++++++++++++++++++ cmd/dashboard/main.go | 6 ++ cmd/dashboard/rpc/service_dispatch.go | 15 ++- .../service_dispatch_type_security_test.go | 59 ++++++++++++ model/config.go | 7 +- model/server_api.go | 8 +- model/service.go | 44 +++++++-- model/service_type_security_test.go | 45 +++++++++ service/rpc/auth.go | 3 + service/singleton/frontend-templates.yaml | 4 + .../singleton/service_type_security_test.go | 37 ++++++++ service/singleton/servicesentinel.go | 17 ++++ service/singleton/user.go | 5 +- 16 files changed, 338 insertions(+), 20 deletions(-) create mode 100644 cmd/dashboard/controller/service_type_security_test.go create mode 100644 cmd/dashboard/rpc/service_dispatch_type_security_test.go create mode 100644 model/service_type_security_test.go create mode 100644 service/singleton/service_type_security_test.go diff --git a/cmd/dashboard/controller/oauth2.go b/cmd/dashboard/controller/oauth2.go index 0cf5fed1..3cb37632 100644 --- a/cmd/dashboard/controller/oauth2.go +++ b/cmd/dashboard/controller/oauth2.go @@ -26,8 +26,11 @@ import ( // code to their own origin and bind the victim's identity. A request Host is // trusted only when it is an operator-declared dashboard host (the same // allowlist that guards NAT routing). Otherwise the redirect is pinned to the -// operator-declared DashboardHost; when DashboardHost is empty the operator has -// not pinned a dashboard origin, so the request Host is passed through. +// operator-declared DashboardHost. Empty DashboardHost intentionally retains +// dynamic/multi-domain deployments by passing through request Host; those +// deployments must validate Host at their trusted proxy and register exact +// redirect URIs at the OAuth provider. GHSA-rf68-8gjr-36q7 documents this +// configuration boundary and must be updated if this compatibility changes. func getRedirectURL(c *gin.Context) string { scheme := "http://" referer := c.Request.Referer() diff --git a/cmd/dashboard/controller/permission_matrix_test.go b/cmd/dashboard/controller/permission_matrix_test.go index 84fcbbcf..9fd34c4a 100644 --- a/cmd/dashboard/controller/permission_matrix_test.go +++ b/cmd/dashboard/controller/permission_matrix_test.go @@ -261,8 +261,8 @@ func TestShowServiceFiltersCycleTransferStatsLikeServerList(t *testing.T) { assert.NoError(t, singleton.DB.Create(&model.Server{Common: model.Common{ID: 3, UserID: 200}, Name: "hidden member server", UUID: "hidden-member-server", HideForGuest: true}).Error) singleton.ServerShared = singleton.NewServerClass() - assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 10, UserID: 1}, Name: "shown service"}).Error) - assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 11, UserID: 1}, Name: "hidden service", HideForGuest: true}).Error) + assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 10, UserID: 1}, Name: "shown service", Type: model.TaskTypeTCPPing}).Error) + assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 11, UserID: 1}, Name: "hidden service", Type: model.TaskTypeTCPPing, HideForGuest: true}).Error) originalServiceSentinel := singleton.ServiceSentinelShared serviceSentinel, err := singleton.NewServiceSentinel(make(chan *model.Service, 2)) diff --git a/cmd/dashboard/controller/service.go b/cmd/dashboard/controller/service.go index 3cd54464..dbf54df9 100644 --- a/cmd/dashboard/controller/service.go +++ b/cmd/dashboard/controller/service.go @@ -484,6 +484,9 @@ func createService(c *gin.Context) (uint64, error) { if err := c.ShouldBindJSON(&mf); err != nil { return 0, err } + if err := model.ValidateServiceMonitorType(uint64(mf.Type)); err != nil { + return 0, err + } if !isValidServiceCover(mf.Cover) { return 0, singleton.Localizer.ErrorT("permission denied") @@ -548,6 +551,9 @@ func updateService(c *gin.Context) (any, error) { if err := c.ShouldBindJSON(&mf); err != nil { return nil, err } + if err := model.ValidateServiceMonitorType(uint64(mf.Type)); err != nil { + return nil, err + } if !isValidServiceCover(mf.Cover) { return nil, singleton.Localizer.ErrorT("permission denied") diff --git a/cmd/dashboard/controller/service_type_security_test.go b/cmd/dashboard/controller/service_type_security_test.go new file mode 100644 index 00000000..e18fd7ac --- /dev/null +++ b/cmd/dashboard/controller/service_type_security_test.go @@ -0,0 +1,91 @@ +package controller + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/service/singleton" +) + +func serviceTypeSecurityRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + setAuthUser(c, 100, model.RoleMember) + c.Next() + }) + r.POST("/api/v1/service", commonHandler(createService)) + r.PATCH("/api/v1/service/:id", commonHandler(updateService)) + return r +} + +func serviceTypeSecurityBody(taskType uint8) []byte { + body, _ := json.Marshal(model.ServiceForm{ + Name: "service-type-security", + Target: "example.invalid:443", + Type: taskType, + Cover: model.ServiceCoverIgnoreAll, + SkipServers: map[uint64]bool{1: true}, + Duration: 30, + }) + return body +} + +func TestCreateServiceRejectsNonProbeTaskTypes(t *testing.T) { + setupCoverPATFixture(t) + r := serviceTypeSecurityRouter() + + for _, taskType := range []uint8{0, model.TaskTypeCommand, model.TaskTypeApplyConfig, model.TaskTypeExec, 255} { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/service", bytes.NewReader(serviceTypeSecurityBody(taskType))) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + success, errMsg := decodeCommonResponseError(t, w.Body.Bytes()) + require.False(t, success, "type %d must be rejected", taskType) + require.Contains(t, errMsg, "invalid service monitor type") + } + + var count int64 + require.NoError(t, singleton.DB.Model(&model.Service{}).Count(&count).Error) + require.Zero(t, count, "rejected task types must not reach persistence") +} + +func TestUpdateServiceRejectsNonProbeTaskTypes(t *testing.T) { + setupCoverPATFixture(t) + r := serviceTypeSecurityRouter() + service := &model.Service{ + Common: model.Common{UserID: 100}, + Name: "valid-service", + Target: "example.invalid:443", + Type: model.TaskTypeTCPPing, + Cover: model.ServiceCoverIgnoreAll, + SkipServers: map[uint64]bool{1: true}, + Duration: 30, + } + require.NoError(t, singleton.DB.Create(service).Error) + + for _, taskType := range []uint8{model.TaskTypeCommand, model.TaskTypeApplyConfig, model.TaskTypeExec, 255} { + w := httptest.NewRecorder() + path := fmt.Sprintf("/api/v1/service/%d", service.ID) + req := httptest.NewRequest(http.MethodPatch, path, bytes.NewReader(serviceTypeSecurityBody(taskType))) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + success, errMsg := decodeCommonResponseError(t, w.Body.Bytes()) + require.False(t, success, "type %d must be rejected", taskType) + require.Contains(t, errMsg, "invalid service monitor type") + + var persisted model.Service + require.NoError(t, singleton.DB.First(&persisted, service.ID).Error) + require.Equal(t, uint8(model.TaskTypeTCPPing), persisted.Type) + } +} diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 928222ae..69b905db 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -46,6 +46,12 @@ func initSystem(bus chan<- *model.Service) error { if err := singleton.DB.Model(&model.User{}).Count(&usersCount).Error; err != nil { return err } + // Backward-compatible bootstrap state: existing installers and recovery + // procedures expect the first login on an empty database to be admin/admin. + // This is not a permanent credential or an authentication-bypass fallback; + // operators must complete initialization and change it before exposing the + // Dashboard. Replacing it requires a coordinated installer/migration flow so + // existing unattended installations are not locked out. if usersCount == 0 { hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) if err != nil { diff --git a/cmd/dashboard/rpc/service_dispatch.go b/cmd/dashboard/rpc/service_dispatch.go index 6304f94e..32e2ed3d 100644 --- a/cmd/dashboard/rpc/service_dispatch.go +++ b/cmd/dashboard/rpc/service_dispatch.go @@ -14,6 +14,17 @@ func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) { if task == nil { continue } + if err := model.ValidateServiceMonitorType(uint64(task.Type)); err != nil { + // Defense in depth for stale database rows and future internal callers: + // Service.Type shares its integer namespace with command/config tasks. + log.Printf("NEZHA>> DispatchTask rejected service %d: %v", task.ID, err) + continue + } + probe := task.PB() + if probe == nil { + log.Printf("NEZHA>> DispatchTask rejected service %d: invalid probe", task.ID) + continue + } switch task.Cover { case model.ServiceCoverIgnoreAll: @@ -29,7 +40,7 @@ func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) { if !canSendTaskToServer(task, server) { continue } - if err := server.SendTask(task.PB()); err != nil && !errors.Is(err, model.ErrTaskStreamOffline) { + if err := server.SendTask(probe); err != nil && !errors.Is(err, model.ErrTaskStreamOffline) { log.Printf("NEZHA>> DispatchTask send error (server=%d): %v", id, err) } } @@ -41,7 +52,7 @@ func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) { if !canSendTaskToServer(task, server) { continue } - if err := server.SendTask(task.PB()); err != nil && !errors.Is(err, model.ErrTaskStreamOffline) { + if err := server.SendTask(probe); err != nil && !errors.Is(err, model.ErrTaskStreamOffline) { log.Printf("NEZHA>> DispatchTask send error (server=%d): %v", id, err) } } diff --git a/cmd/dashboard/rpc/service_dispatch_type_security_test.go b/cmd/dashboard/rpc/service_dispatch_type_security_test.go new file mode 100644 index 00000000..a5eebe9f --- /dev/null +++ b/cmd/dashboard/rpc/service_dispatch_type_security_test.go @@ -0,0 +1,59 @@ +package rpc + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/service/singleton" +) + +func TestDispatchTaskSendsOnlyProbeTypes(t *testing.T) { + originalServerShared := singleton.ServerShared + originalUserInfo := singleton.UserInfoMap + t.Cleanup(func() { + singleton.ServerShared = originalServerShared + singleton.UserLock.Lock() + singleton.UserInfoMap = originalUserInfo + singleton.UserLock.Unlock() + }) + + server := &model.Server{Common: model.Common{ID: 1, UserID: 100}} + stream := &serveNATTaskStream{} + server.SetTaskStream(stream) + serverShared := singleton.NewEmptyServerClassForTest() + serverShared.InsertForTest(server) + singleton.ServerShared = serverShared + singleton.UserLock.Lock() + singleton.UserInfoMap = map[uint64]model.UserInfo{100: {Role: model.RoleMember}} + singleton.UserLock.Unlock() + + bus := make(chan *model.Service, 8) + done := make(chan struct{}) + go func() { + DispatchTask(bus) + close(done) + }() + for _, taskType := range []uint8{model.TaskTypeCommand, model.TaskTypeApplyConfig, model.TaskTypeExec, 255} { + bus <- &model.Service{ + Common: model.Common{ID: uint64(taskType), UserID: 100}, + Type: taskType, + Cover: model.ServiceCoverIgnoreAll, + SkipServers: map[uint64]bool{1: true}, + } + } + bus <- &model.Service{ + Common: model.Common{ID: 1000, UserID: 100}, + Type: model.TaskTypeTCPPing, + Target: "example.invalid:443", + Cover: model.ServiceCoverIgnoreAll, + SkipServers: map[uint64]bool{1: true}, + } + close(bus) + <-done + + require.Len(t, stream.sent, 1) + require.Equal(t, uint64(model.TaskTypeTCPPing), stream.sent[0].GetType()) + require.Equal(t, uint64(1000), stream.sent[0].GetId()) +} diff --git a/model/config.go b/model/config.go index d432b0d6..3e766b2f 100644 --- a/model/config.go +++ b/model/config.go @@ -43,7 +43,12 @@ type ConfigForGuests struct { type ConfigDashboard struct { InstallHost string `koanf:"install_host" json:"install_host,omitempty"` - AgentTLS bool `koanf:"tls" json:"tls,omitempty"` // 用于前端判断生成的安装命令是否启用 TLS + // AgentTLS controls the transport emitted by Agent installation commands. + // false intentionally supports trusted private networks and does not provide + // Dashboard peer authentication; Internet-facing control planes must use + // verified TLS. Changing this compatibility default belongs in the installer + // migration path, not in the gRPC task authorization model. + AgentTLS bool `koanf:"tls" json:"tls,omitempty"` // DashboardHost 是 dashboard 对外访问的主机名,专用于 OAuth2 回调地址。 // 它与 InstallHost(agent 连接用主机名)解耦:两者可以是不同域名。 diff --git a/model/server_api.go b/model/server_api.go index 846a4925..c416c4c2 100644 --- a/model/server_api.go +++ b/model/server_api.go @@ -21,8 +21,12 @@ type StreamServerData struct { } type ServerForm struct { - Name string `json:"name,omitempty"` - Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注 + Name string `json:"name,omitempty"` + Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注 + // PublicNote is opaque public metadata consumed by independently maintained + // user themes. The Dashboard stores/transports it but never renders it as + // HTML or navigates URL-like fields. Themes must validate schemes before + // using nested values such as customData.orderLink in href/window.open. PublicNote string `json:"public_note,omitempty" validate:"optional"` // 公开备注 DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前 HideForGuest bool `json:"hide_for_guest,omitempty" validate:"optional"` // 对游客隐藏 diff --git a/model/service.go b/model/service.go index 701bedb8..fe7e14a0 100644 --- a/model/service.go +++ b/model/service.go @@ -39,6 +39,30 @@ const ( TaskTypeFsTransfer ) +// IsServiceMonitorType reports whether t is a passive service probe. Service +// monitors and privileged Agent-control tasks share the protobuf Task.Type +// namespace, so every path that persists, schedules, or dispatches a Service +// must use this allowlist instead of accepting an arbitrary task integer. +func IsServiceMonitorType(t uint64) bool { + switch t { + case TaskTypeHTTPGet, TaskTypeICMPPing, TaskTypeTCPPing: + return true + default: + return false + } +} + +// ValidateServiceMonitorType returns an actionable error at API, model, and +// scheduler boundaries. Keeping the check in model avoids a future caller +// accidentally turning a monitor-only capability into Agent command/config +// execution by copying Service.Type into pb.Task.Type. +func ValidateServiceMonitorType(t uint64) error { + if !IsServiceMonitorType(t) { + return fmt.Errorf("invalid service monitor type %d: allowed types are 1 (HTTP GET), 2 (ICMP ping), and 3 (TCP ping)", t) + } + return nil +} + // IsMCPRPCResult 判定一个 TaskResult.Type 是否属于 MCP 走 RequestTask 通道的 // 一次性 RPC 类型。dashboard 的 RequestTask 接收循环用它把这些回包路由到 // Server.inflightRPC 等待方,而不是走 ServiceSentinel。 @@ -232,6 +256,9 @@ type Service struct { } func (m *Service) PB() *pb.Task { + if m == nil || !IsServiceMonitorType(uint64(m.Type)) { + return nil + } return &pb.Task{ Id: m.ID, Type: uint64(m.Type), @@ -300,6 +327,9 @@ func (m *Service) CronSpec() string { } func (m *Service) BeforeSave(tx *gorm.DB) error { + if err := ValidateServiceMonitorType(uint64(m.Type)); err != nil { + return err + } if data, err := json.Marshal(m.SkipServers); err != nil { return err } else { @@ -336,15 +366,9 @@ func (m *Service) AfterFind(tx *gorm.DB) error { return nil } -// IsServiceSentinelNeeded 判断该任务类型是否需要进行服务监控 需要则返回true +// IsServiceSentinelNeeded accepts results only for the three probe types. An +// unknown or privileged task type must never enter ServiceSentinel merely +// because it was not listed in a denylist. func IsServiceSentinelNeeded(t uint64) bool { - switch t { - case TaskTypeCommand, TaskTypeTerminalGRPC, TaskTypeUpgrade, - TaskTypeKeepalive, TaskTypeNAT, TaskTypeFM, - TaskTypeReportConfig, TaskTypeApplyConfig, - TaskTypeServerTransferApply: - return false - default: - return true - } + return IsServiceMonitorType(t) } diff --git a/model/service_type_security_test.go b/model/service_type_security_test.go new file mode 100644 index 00000000..d248bb74 --- /dev/null +++ b/model/service_type_security_test.go @@ -0,0 +1,45 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestServiceMonitorTypeAllowlist(t *testing.T) { + for _, taskType := range []uint64{TaskTypeHTTPGet, TaskTypeICMPPing, TaskTypeTCPPing} { + require.True(t, IsServiceMonitorType(taskType), "probe type %d must remain allowed", taskType) + require.NoError(t, ValidateServiceMonitorType(taskType)) + require.True(t, IsServiceSentinelNeeded(taskType)) + } + + for _, taskType := range []uint64{ + 0, + TaskTypeCommand, + TaskTypeApplyConfig, + TaskTypeServerTransferApply, + TaskTypeExec, + TaskTypeFsTransfer, + 255, + } { + require.False(t, IsServiceMonitorType(taskType), "privileged/unknown type %d must be rejected", taskType) + require.Error(t, ValidateServiceMonitorType(taskType)) + require.False(t, IsServiceSentinelNeeded(taskType)) + } +} + +func TestServicePersistenceAndPBRejectPrivilegedTaskTypes(t *testing.T) { + for _, taskType := range []uint8{0, TaskTypeCommand, TaskTypeApplyConfig, TaskTypeExec, 255} { + service := &Service{Type: taskType} + require.Error(t, service.BeforeSave(nil), "type %d must not be persisted", taskType) + require.Nil(t, service.PB(), "type %d must not become an Agent task", taskType) + } + + service := &Service{Common: Common{ID: 7}, Type: TaskTypeTCPPing, Target: "example.invalid:443"} + require.NoError(t, service.BeforeSave(nil)) + task := service.PB() + require.NotNil(t, task) + require.Equal(t, uint64(7), task.GetId()) + require.Equal(t, uint64(TaskTypeTCPPing), task.GetType()) + require.Equal(t, service.Target, task.GetData()) +} diff --git a/service/rpc/auth.go b/service/rpc/auth.go index cd6fb3c2..842426b3 100644 --- a/service/rpc/auth.go +++ b/service/rpc/auth.go @@ -222,6 +222,9 @@ func authorizeAgentForUUID(userId uint64, clientUUID string) (clientID uint64, h if userId == 0 { // The legacy global agent secret maps to user 0. It predates per-user // agent secrets, so keep it compatible by allowing any existing UUID. + // Possession of this deployment-wide master credential is therefore not + // a tenant-scoped authorization claim. Removal must follow an inventory and + // credential-rotation migration or legacy Agents will be locked out. return cid, true, nil } if server.GetUserID() == userId { diff --git a/service/singleton/frontend-templates.yaml b/service/singleton/frontend-templates.yaml index 34139a7d..5a94ca8e 100644 --- a/service/singleton/frontend-templates.yaml +++ b/service/singleton/frontend-templates.yaml @@ -16,6 +16,10 @@ repository: "https://github.com/karllao/nezha-pixel" author: "karllao" version: "v1.6.0" +# Third-party user themes consume the opaque Server.PublicNote field. Theme +# maintainers must validate URL schemes (including after decoding) before using +# values such as customData.orderLink in href or window.open; the Dashboard +# backend and admin frontend do not execute those fields. - path: "nazhua-dist" name: "Nazhua" repository: "https://github.com/hi2shark/nazhua" diff --git a/service/singleton/service_type_security_test.go b/service/singleton/service_type_security_test.go new file mode 100644 index 00000000..24524e32 --- /dev/null +++ b/service/singleton/service_type_security_test.go @@ -0,0 +1,37 @@ +package singleton + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/nezhahq/nezha/model" +) + +func TestServiceSentinelUpdateRejectsNonProbeTaskTypes(t *testing.T) { + ss := &ServiceSentinel{} + require.Error(t, ss.Update(nil)) + for _, taskType := range []uint8{0, model.TaskTypeCommand, model.TaskTypeApplyConfig, model.TaskTypeExec, 255} { + require.Error(t, ss.Update(&model.Service{Type: taskType}), "type %d must not be scheduled", taskType) + } +} + +func TestServiceSentinelQuarantinesInvalidPersistedTypes(t *testing.T) { + ss := newServiceMonitorSecurityHarness(t) + + insert := `INSERT INTO services + (id, user_id, name, type, target, duration, cover, skip_servers_raw, fail_trigger_tasks_raw, recover_trigger_tasks_raw) + VALUES (?, 100, ?, ?, 'example.invalid:443', 3600, ?, '{}', '[]', '[]')` + require.NoError(t, DB.Exec(insert, 91, "legacy-command", model.TaskTypeCommand, model.ServiceCoverIgnoreAll).Error) + require.NoError(t, DB.Exec(insert, 92, "legacy-apply-config", model.TaskTypeApplyConfig, model.ServiceCoverIgnoreAll).Error) + require.NoError(t, DB.Exec(insert, 93, "valid-probe", model.TaskTypeTCPPing, model.ServiceCoverIgnoreAll).Error) + + require.NoError(t, ss.loadServiceHistory()) + _, commandLoaded := ss.Get(91) + _, applyConfigLoaded := ss.Get(92) + valid, validLoaded := ss.Get(93) + require.False(t, commandLoaded) + require.False(t, applyConfigLoaded) + require.True(t, validLoaded) + require.Equal(t, uint8(model.TaskTypeTCPPing), valid.Type) +} diff --git a/service/singleton/servicesentinel.go b/service/singleton/servicesentinel.go index dd8882ff..a2c6ef8d 100644 --- a/service/singleton/servicesentinel.go +++ b/service/singleton/servicesentinel.go @@ -209,7 +209,15 @@ func (ss *ServiceSentinel) loadServiceHistory() error { return err } + validServices := services[:0] for _, service := range services { + if err := model.ValidateServiceMonitorType(uint64(service.Type)); err != nil { + // Existing databases may contain values written before Service.Type was + // constrained. Quarantine them in the database for operator review, but + // never register a cron job that could dispatch a privileged Agent task. + log.Printf("NEZHA>> quarantining service %d: %v", service.ID, err) + continue + } task := service // 通过cron定时将服务监控任务传递给任务调度管道 service.CronJobID, err = CronShared.AddFunc(task.CronSpec(), func() { @@ -222,7 +230,9 @@ func (ss *ServiceSentinel) loadServiceHistory() error { ss.serviceCurrentStatusData[service.ID] = new(serviceTaskStatus) ss.serviceCurrentStatusData[service.ID].result = make([]*pb.TaskResult, 0, _CurrentStatusSize) ss.serviceStatusToday[service.ID] = &_TodayStatsOfService{} + validServices = append(validServices, service) } + services = validServices ss.serviceList = services sortServices(ss.serviceList) @@ -339,6 +349,13 @@ func (ss *ServiceSentinel) loadTodayStats(today time.Time) { } func (ss *ServiceSentinel) Update(m *model.Service) error { + if m == nil { + return fmt.Errorf("service is nil") + } + if err := model.ValidateServiceMonitorType(uint64(m.Type)); err != nil { + return err + } + ss.serviceResponseDataStoreLock.Lock() defer ss.serviceResponseDataStoreLock.Unlock() ss.monthlyStatusLock.Lock() diff --git a/service/singleton/user.go b/service/singleton/user.go index 69fdce02..7bfb5d9e 100644 --- a/service/singleton/user.go +++ b/service/singleton/user.go @@ -23,7 +23,10 @@ func initUser() { var users []model.User DB.Find(&users) - // for backward compatibility + // Backward compatibility for pre-user-scoped Agents. AgentSecretKey is a + // deployment-wide migration/master credential, so user 0 is intentionally + // not tenant-scoped. Do not remove this mapping until every legacy Agent has + // rotated to a per-user/per-Agent credential; doing so would disconnect them. UserInfoMap[0] = model.UserInfo{ Role: model.RoleAdmin, AgentSecret: Conf.AgentSecretKey,