mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-05 00:50:21 +00:00
fix(alert): prevent persisted rule crash loops
This commit is contained in:
1 parent
8f1147c800
commit
19daf4ad3c
8 files changed
+434
-26
No files matched your search
@@ -172,6 +172,12 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
|
|||||||
}
|
}
|
||||||
if len(r.Rules) > 0 {
|
if len(r.Rules) > 0 {
|
||||||
for _, rule := range r.Rules {
|
for _, rule := range r.Rules {
|
||||||
|
if rule == nil {
|
||||||
|
return singleton.Localizer.ErrorT("rule is not set")
|
||||||
|
}
|
||||||
|
if !rule.IsSupportedType() {
|
||||||
|
return singleton.Localizer.ErrorT("unsupported rule type")
|
||||||
|
}
|
||||||
switch rule.Cover {
|
switch rule.Cover {
|
||||||
case model.RuleCoverAll, model.RuleCoverIgnoreAll:
|
case model.RuleCoverAll, model.RuleCoverIgnoreAll:
|
||||||
default:
|
default:
|
||||||
@@ -182,10 +188,16 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
|
|||||||
if rule.Duration < 3 {
|
if rule.Duration < 3 {
|
||||||
return singleton.Localizer.ErrorT("duration need to be at least 3")
|
return singleton.Localizer.ErrorT("duration need to be at least 3")
|
||||||
}
|
}
|
||||||
|
if rule.Duration > model.MaxAlertRuleDuration {
|
||||||
|
return singleton.Localizer.ErrorT("duration is too large")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if rule.CycleInterval < 1 {
|
if rule.CycleInterval < 1 {
|
||||||
return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
|
return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
|
||||||
}
|
}
|
||||||
|
if rule.CycleInterval > model.MaxAlertRuleCycleInterval {
|
||||||
|
return singleton.Localizer.ErrorT("cycle_interval is too large")
|
||||||
|
}
|
||||||
if rule.CycleStart == nil {
|
if rule.CycleStart == nil {
|
||||||
return singleton.Localizer.ErrorT("cycle_start is not set")
|
return singleton.Localizer.ErrorT("cycle_start is not set")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateRuleRejectsCrashPayloadsFromMember(t *testing.T) {
|
||||||
|
ctx := newMemberValidationContext(t)
|
||||||
|
cycleStart := time.Now().Add(-time.Hour)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rule *model.Rule
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unknown rule type",
|
||||||
|
rule: &model.Rule{Type: "attacker_controlled", Duration: 3, Cover: model.RuleCoverAll},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duration overflows int",
|
||||||
|
rule: &model.Rule{Type: "offline", Duration: math.MaxUint64, Cover: model.RuleCoverAll},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycle interval overflows int",
|
||||||
|
rule: &model.Rule{
|
||||||
|
Type: "transfer_in_cycle",
|
||||||
|
CycleStart: &cycleStart,
|
||||||
|
CycleInterval: math.MaxUint64,
|
||||||
|
Cover: model.RuleCoverAll,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil rule",
|
||||||
|
rule: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{UserID: 200},
|
||||||
|
Name: "security regression",
|
||||||
|
Rules: []*model.Rule{test.rule},
|
||||||
|
}
|
||||||
|
require.Error(t, validateRule(ctx, alert))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRuleAcceptsSafeDurationBoundary(t *testing.T) {
|
||||||
|
ctx := newMemberValidationContext(t)
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{UserID: 200},
|
||||||
|
Name: "duration boundary",
|
||||||
|
Rules: []*model.Rule{{
|
||||||
|
Type: "offline",
|
||||||
|
Duration: model.MaxAlertRuleDuration,
|
||||||
|
Cover: model.RuleCoverAll,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
require.NoError(t, validateRule(ctx, alert))
|
||||||
|
}
|
||||||
+65
-12
@@ -64,6 +64,36 @@ func (r *AlertRule) Enabled() bool {
|
|||||||
return r.Enable != nil && *r.Enable
|
return r.Enable != nil && *r.Enable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSafeToEvaluate validates persisted rule data before it reaches the alert
|
||||||
|
// goroutine. API validation protects new writes; this second boundary protects
|
||||||
|
// upgrades from malformed or deliberately poisoned rows already in the DB.
|
||||||
|
func (r *AlertRule) IsSafeToEvaluate() bool {
|
||||||
|
if r == nil || len(r.Rules) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, rule := range r.Rules {
|
||||||
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch rule.Cover {
|
||||||
|
case RuleCoverAll, RuleCoverIgnoreAll:
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.IsTransferDurationRule() {
|
||||||
|
if !rule.HasSafeCycleConfiguration() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
duration, ok := rule.DurationInt()
|
||||||
|
if !ok || duration < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// HasPermission extends the default owner/admin check with PAT
|
// HasPermission extends the default owner/admin check with PAT
|
||||||
// server_ids whitelist enforcement. AlertRule.Snapshot fans out across
|
// server_ids whitelist enforcement. AlertRule.Snapshot fans out across
|
||||||
// every owner-visible server filtered only by Rule.Ignore semantics
|
// every owner-visible server filtered only by Rule.Ignore semantics
|
||||||
@@ -125,6 +155,12 @@ func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Ser
|
|||||||
point := make([]bool, len(r.Rules))
|
point := make([]bool, len(r.Rules))
|
||||||
|
|
||||||
for i, rule := range r.Rules {
|
for i, rule := range r.Rules {
|
||||||
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
// Invalid persisted rules are ignored instead of being interpreted as
|
||||||
|
// a failed condition or allowed to panic the sentinel.
|
||||||
|
point[i] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
point[i] = rule.Snapshot(cycleTransferStats, server, db)
|
point[i] = rule.Snapshot(cycleTransferStats, server, db)
|
||||||
}
|
}
|
||||||
return point
|
return point
|
||||||
@@ -134,10 +170,14 @@ func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Ser
|
|||||||
func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
||||||
var hasPassedRule bool
|
var hasPassedRule bool
|
||||||
durations := make([]int, len(r.Rules))
|
durations := make([]int, len(r.Rules))
|
||||||
|
validRules := 0
|
||||||
|
|
||||||
for ruleIndex, rule := range r.Rules {
|
for ruleIndex, rule := range r.Rules {
|
||||||
duration := int(rule.Duration)
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if rule.IsTransferDurationRule() {
|
if rule.IsTransferDurationRule() {
|
||||||
|
validRules++
|
||||||
// 循环区间流量报警
|
// 循环区间流量报警
|
||||||
if durations[ruleIndex] < 1 {
|
if durations[ruleIndex] < 1 {
|
||||||
durations[ruleIndex] = 1
|
durations[ruleIndex] = 1
|
||||||
@@ -146,10 +186,21 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 只要最后一次检查超出了规则范围 就认为检查未通过
|
// 只要最后一次检查超出了规则范围 就认为检查未通过
|
||||||
if len(points) > 0 && points[len(points)-1][ruleIndex] {
|
if len(points) > 0 {
|
||||||
|
lastPoint := points[len(points)-1]
|
||||||
|
if ruleIndex >= len(lastPoint) || lastPoint[ruleIndex] {
|
||||||
hasPassedRule = true
|
hasPassedRule = true
|
||||||
}
|
}
|
||||||
} else if rule.IsOfflineRule() {
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, ok := rule.DurationInt()
|
||||||
|
if !ok || duration <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validRules++
|
||||||
|
if rule.IsOfflineRule() {
|
||||||
// 离线报警,检查直到最后一次在线的离线采样点是否大于 duration
|
// 离线报警,检查直到最后一次在线的离线采样点是否大于 duration
|
||||||
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
||||||
continue
|
continue
|
||||||
@@ -157,7 +208,7 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
var fail int
|
var fail int
|
||||||
for _, point := range slices.Backward(points[len(points)-duration:]) {
|
for _, point := range slices.Backward(points[len(points)-duration:]) {
|
||||||
fail++
|
fail++
|
||||||
if point[ruleIndex] {
|
if ruleIndex >= len(point) || point[ruleIndex] {
|
||||||
hasPassedRule = true
|
hasPassedRule = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -168,11 +219,7 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
// 常规报警
|
// 常规报警
|
||||||
// duration<=0 是无意义的规则(持续 0 秒):直接跳过该规则,
|
// duration<=0 是无意义的规则(持续 0 秒):直接跳过该规则,
|
||||||
// 既不污染 hasPassedRule(否则会连带跳过同一 alert 里其它有效
|
// 既不污染 hasPassedRule(否则会连带跳过同一 alert 里其它有效
|
||||||
// 规则),也避免下方 fail*100/total 在 total=0 时整数除零 panic
|
// 规则),也避免下方百分比计算在 total=0 时除零。
|
||||||
// —— checkStatus 无 recover,一次 panic 会拖垮整个告警 goroutine。
|
|
||||||
if duration <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -181,17 +228,20 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
}
|
}
|
||||||
total, fail := duration, 0
|
total, fail := duration, 0
|
||||||
for timeTick := len(points) - duration; timeTick < len(points); timeTick++ {
|
for timeTick := len(points) - duration; timeTick < len(points); timeTick++ {
|
||||||
if !points[timeTick][ruleIndex] {
|
if ruleIndex >= len(points[timeTick]) || !points[timeTick][ruleIndex] {
|
||||||
fail++
|
fail++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 当70%以上的采样点未通过规则判断时 才认为当前检查未通过
|
// 当70%以上的采样点未通过规则判断时 才认为当前检查未通过
|
||||||
if fail*100/total <= 70 {
|
if float64(fail)*100/float64(total) <= 70 {
|
||||||
hasPassedRule = true
|
hasPassedRule = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if validRules == 0 {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
// 仅当所有检查均未通过时 才触发告警
|
// 仅当所有检查均未通过时 才触发告警
|
||||||
return slices.Max(durations), hasPassedRule
|
return slices.Max(durations), hasPassedRule
|
||||||
}
|
}
|
||||||
@@ -205,10 +255,13 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
func (r *AlertRule) RetentionWindow() int {
|
func (r *AlertRule) RetentionWindow() int {
|
||||||
window := 0
|
window := 0
|
||||||
for _, rule := range r.Rules {
|
for _, rule := range r.Rules {
|
||||||
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var need int
|
var need int
|
||||||
if rule.IsTransferDurationRule() {
|
if rule.IsTransferDurationRule() {
|
||||||
need = 1
|
need = 1
|
||||||
} else if d := int(rule.Duration); d > 0 {
|
} else if d, ok := rule.DurationInt(); ok && d > 0 {
|
||||||
need = d
|
need = d
|
||||||
}
|
}
|
||||||
if need > window {
|
if need > window {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuleSnapshotIgnoresEmptyMetricSamples(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ruleType string
|
||||||
|
hostState *HostState
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GPU list is empty",
|
||||||
|
ruleType: "gpu_max",
|
||||||
|
hostState: &HostState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all temperature samples are filtered",
|
||||||
|
ruleType: "temperature_max",
|
||||||
|
hostState: &HostState{Temperatures: []SensorTemperature{
|
||||||
|
{Name: "coretemp", Temperature: 0},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := &Server{Common: Common{ID: 1}, State: test.hostState, Host: &Host{}}
|
||||||
|
rule := &Rule{Type: test.ruleType, Max: 1, Duration: 3, Cover: RuleCoverAll}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
t.Fatalf("Snapshot panicked for missing metric data: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if passed := rule.Snapshot(nil, server, nil); !passed {
|
||||||
|
t.Fatal("missing metric data must be ignored instead of treated as a failed threshold")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRuleCheckRejectsOverflowingPersistedDuration(t *testing.T) {
|
||||||
|
rule := &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "offline",
|
||||||
|
Duration: math.MaxUint64,
|
||||||
|
Cover: RuleCoverAll,
|
||||||
|
}}}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
t.Fatalf("Check panicked after uint64-to-int overflow: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
duration, passed := rule.Check([][]bool{{false}})
|
||||||
|
if duration != 0 || !passed {
|
||||||
|
t.Fatalf("invalid persisted duration must be ignored safely, got duration=%d passed=%v", duration, passed)
|
||||||
|
}
|
||||||
|
if window := rule.RetentionWindow(); window != 0 {
|
||||||
|
t.Fatalf("invalid persisted duration must not create a retention window, got %d", window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRulePersistedDataSafety(t *testing.T) {
|
||||||
|
cycleStart := time.Now().Add(-time.Hour)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rule *AlertRule
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal rule",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "cpu", Duration: 3, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normal cycle rule",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "transfer_in_cycle", CycleStart: &cycleStart, CycleInterval: 1, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown type",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "attacker_controlled", Duration: 3, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overflowing duration",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "offline", Duration: math.MaxUint64, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycle without start",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "transfer_in_cycle", CycleInterval: 1, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil rule",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{nil}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty rule list",
|
||||||
|
rule: &AlertRule{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := test.rule.IsSafeToEvaluate(); got != test.want {
|
||||||
|
t.Fatalf("IsSafeToEvaluate()=%v, want %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-4
@@ -26,7 +26,7 @@ func testCycleRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
Type: "_cycle",
|
Type: "transfer_in_cycle",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -39,7 +39,7 @@ func testCycleRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
Type: "_cycle",
|
Type: "transfer_in_cycle",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -130,6 +130,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -143,6 +144,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -156,6 +158,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -169,6 +172,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -182,6 +186,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -210,6 +215,7 @@ func testCombinedRules(t *testing.T) {
|
|||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -227,6 +233,7 @@ func testCombinedRules(t *testing.T) {
|
|||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -240,6 +247,7 @@ func testCombinedRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -257,9 +265,11 @@ func testCombinedRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Type: "memory",
|
||||||
Duration: 30,
|
Duration: 30,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -273,9 +283,11 @@ func testCombinedRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Type: "memory",
|
||||||
Duration: 30,
|
Duration: 30,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -416,7 +428,7 @@ func TestAlertRule_RetentionWindow(t *testing.T) {
|
|||||||
{"zero duration only", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}}}, 0},
|
{"zero duration only", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}}}, 0},
|
||||||
{"mixed picks max", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}, {Type: "cpu", Duration: 7}}}, 7},
|
{"mixed picks max", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}, {Type: "cpu", Duration: 7}}}, 7},
|
||||||
{"offline keeps Duration", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 30}}}, 30},
|
{"offline keeps Duration", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 30}}}, 30},
|
||||||
{"cycle looks back one", &AlertRule{Rules: []*Rule{{Type: "net_in_speed_cycle"}}}, 1},
|
{"cycle looks back one", &AlertRule{Rules: []*Rule{{Type: "transfer_in_cycle"}}}, 1},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := c.rule.RetentionWindow(); got != c.want {
|
if got := c.rule.RetentionWindow(); got != c.want {
|
||||||
@@ -469,7 +481,7 @@ func TestAlertRule_CombinedRuleAccumulatesSamples(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"general3+general10", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 3}, {Type: "memory", Duration: 10}}}, []bool{false, false}, 9, 10},
|
{"general3+general10", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 3}, {Type: "memory", Duration: 10}}}, []bool{false, false}, 9, 10},
|
||||||
{"offline5+general10", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 5}, {Type: "cpu", Duration: 10}}}, []bool{false, false}, 9, 10},
|
{"offline5+general10", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 5}, {Type: "cpu", Duration: 10}}}, []bool{false, false}, 9, 10},
|
||||||
{"transfer+general8", &AlertRule{Rules: []*Rule{{Type: "net_in_speed_cycle"}, {Type: "cpu", Duration: 8}}}, []bool{false, false}, 7, 8},
|
{"transfer+general8", &AlertRule{Rules: []*Rule{{Type: "transfer_in_cycle"}, {Type: "cpu", Duration: 8}}}, []bool{false, false}, 7, 8},
|
||||||
{"offline3+offline12", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 3}, {Type: "offline", Duration: 12}}}, []bool{false, false}, 11, 12},
|
{"offline3+offline12", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 3}, {Type: "offline", Duration: 12}}}, []bool{false, false}, 11, 12},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
+75
-6
@@ -15,12 +15,23 @@ const (
|
|||||||
RuleCoverIgnoreAll
|
RuleCoverIgnoreAll
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxAlertRuleDuration is the largest duration that can be converted to int
|
||||||
|
// on every architecture supported by Go. Alert rules are persisted as uint64,
|
||||||
|
// but their sampling windows use int indexes; keeping the public bound at
|
||||||
|
// MaxInt32 prevents a crafted value from wrapping during that conversion.
|
||||||
|
const MaxAlertRuleDuration uint64 = 1<<31 - 1
|
||||||
|
|
||||||
|
// MaxAlertRuleCycleInterval also leaves room for the largest integer
|
||||||
|
// multiplication performed by the calendar helpers (weeks become 7*interval)
|
||||||
|
// on 32-bit targets.
|
||||||
|
const MaxAlertRuleCycleInterval uint64 = (1<<31 - 1) / 7
|
||||||
|
|
||||||
type NResult struct {
|
type NResult struct {
|
||||||
N uint64
|
N uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
// 指标类型,cpu、memory、swap、disk、net_in_speed、net_out_speed
|
// 指标类型,cpu、gpu/gpu_max、memory、swap、disk、net_in_speed、net_out_speed
|
||||||
// net_all_speed、transfer_in、transfer_out、transfer_all、offline
|
// net_all_speed、transfer_in、transfer_out、transfer_all、offline
|
||||||
// transfer_in_cycle、transfer_out_cycle、transfer_all_cycle
|
// transfer_in_cycle、transfer_out_cycle、transfer_all_cycle
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -45,8 +56,52 @@ func percentage(used, total uint64) float64 {
|
|||||||
return float64(used) * 100 / float64(total)
|
return float64(used) * 100 / float64(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSupportedType reports whether the rule type has an evaluator. Keep this
|
||||||
|
// list in lockstep with Snapshot's switch so untrusted strings cannot enter the
|
||||||
|
// persisted alert pipeline and silently acquire fallback semantics.
|
||||||
|
func (u *Rule) IsSupportedType() bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch u.Type {
|
||||||
|
case "cpu", "gpu", "gpu_max", "memory", "swap", "disk",
|
||||||
|
"net_in_speed", "net_out_speed", "net_all_speed",
|
||||||
|
"transfer_in", "transfer_out", "transfer_all", "offline",
|
||||||
|
"transfer_in_cycle", "transfer_out_cycle", "transfer_all_cycle",
|
||||||
|
"load1", "load5", "load15", "tcp_conn_count", "udp_conn_count",
|
||||||
|
"process_count", "temperature_max":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DurationInt converts the persisted duration without allowing uint64-to-int
|
||||||
|
// truncation. Callers must handle ok=false as an invalid rule.
|
||||||
|
func (u *Rule) DurationInt() (duration int, ok bool) {
|
||||||
|
if u == nil || u.Duration > MaxAlertRuleDuration {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int(u.Duration), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasSafeCycleConfiguration guards every value that the cycle evaluator later
|
||||||
|
// converts or dereferences. It intentionally does not reject an empty unit,
|
||||||
|
// which is the documented legacy spelling for hours.
|
||||||
|
func (u *Rule) HasSafeCycleConfiguration() bool {
|
||||||
|
return u != nil && u.IsTransferDurationRule() && u.CycleStart != nil &&
|
||||||
|
u.CycleInterval > 0 && u.CycleInterval <= MaxAlertRuleCycleInterval
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot 未通过规则返回 false, 通过返回 true
|
// Snapshot 未通过规则返回 false, 通过返回 true
|
||||||
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) bool {
|
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) bool {
|
||||||
|
if u == nil || server == nil || !u.IsSupportedType() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if u.IsTransferDurationRule() && (!u.HasSafeCycleConfiguration() || cycleTransferStats == nil) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// 监控全部但是排除了此服务器
|
// 监控全部但是排除了此服务器
|
||||||
if u.Cover == RuleCoverAll && u.Ignore[server.ID] {
|
if u.Cover == RuleCoverAll && u.Ignore[server.ID] {
|
||||||
return true
|
return true
|
||||||
@@ -71,7 +126,10 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
|
|||||||
switch u.Type {
|
switch u.Type {
|
||||||
case "cpu":
|
case "cpu":
|
||||||
src = float64(state.CPU)
|
src = float64(state.CPU)
|
||||||
case "gpu_max":
|
case "gpu", "gpu_max":
|
||||||
|
if len(state.GPU) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
src = slices.Max(state.GPU)
|
src = slices.Max(state.GPU)
|
||||||
case "memory":
|
case "memory":
|
||||||
if runtime.Host == nil {
|
if runtime.Host == nil {
|
||||||
@@ -141,14 +199,17 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
|
|||||||
src = float64(state.ProcessCount)
|
src = float64(state.ProcessCount)
|
||||||
case "temperature_max":
|
case "temperature_max":
|
||||||
var temp []float64
|
var temp []float64
|
||||||
if state.Temperatures != nil {
|
|
||||||
for _, tempStat := range state.Temperatures {
|
for _, tempStat := range state.Temperatures {
|
||||||
if tempStat.Temperature != 0 {
|
if tempStat.Temperature != 0 {
|
||||||
temp = append(temp, tempStat.Temperature)
|
temp = append(temp, tempStat.Temperature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
src = slices.Max(temp)
|
if len(temp) == 0 {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
src = slices.Max(temp)
|
||||||
|
default:
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 循环区间流量检测 · 更新下次需要检测时间
|
// 循环区间流量检测 · 更新下次需要检测时间
|
||||||
@@ -187,11 +248,19 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
|
|||||||
|
|
||||||
// IsTransferDurationRule 判断该规则是否属于周期流量规则 属于则返回true
|
// IsTransferDurationRule 判断该规则是否属于周期流量规则 属于则返回true
|
||||||
func (u *Rule) IsTransferDurationRule() bool {
|
func (u *Rule) IsTransferDurationRule() bool {
|
||||||
return strings.HasSuffix(u.Type, "_cycle")
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch u.Type {
|
||||||
|
case "transfer_in_cycle", "transfer_out_cycle", "transfer_all_cycle":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Rule) IsOfflineRule() bool {
|
func (u *Rule) IsOfflineRule() bool {
|
||||||
return u.Type == "offline"
|
return u != nil && u.Type == "offline"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTransferDurationStart 获取周期流量的起始时间
|
// GetTransferDurationStart 获取周期流量的起始时间
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ var (
|
|||||||
|
|
||||||
// addCycleTransferStatsInfo 向AlertsCycleTransferStatsStore中添加周期流量报警统计信息
|
// addCycleTransferStatsInfo 向AlertsCycleTransferStatsStore中添加周期流量报警统计信息
|
||||||
func addCycleTransferStatsInfo(alert *model.AlertRule) {
|
func addCycleTransferStatsInfo(alert *model.AlertRule) {
|
||||||
if !alert.Enabled() {
|
if alert == nil || !alert.Enabled() || !alert.IsSafeToEvaluate() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, rule := range alert.Rules {
|
for _, rule := range alert.Rules {
|
||||||
@@ -67,8 +67,16 @@ func AlertSentinelStart() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
for _, alert := range Alerts {
|
for _, alert := range Alerts {
|
||||||
|
if alert == nil {
|
||||||
|
log.Printf("NEZHA>> Skipping invalid nil alert rule loaded from database")
|
||||||
|
continue
|
||||||
|
}
|
||||||
alertsStore[alert.ID] = make(map[uint64][][]bool)
|
alertsStore[alert.ID] = make(map[uint64][][]bool)
|
||||||
alertsPrevState[alert.ID] = make(map[uint64]uint8)
|
alertsPrevState[alert.ID] = make(map[uint64]uint8)
|
||||||
|
if !alert.IsSafeToEvaluate() {
|
||||||
|
log.Printf("NEZHA>> Skipping invalid alert rule %d loaded from database", alert.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
addCycleTransferStatsInfo(alert)
|
addCycleTransferStatsInfo(alert)
|
||||||
}
|
}
|
||||||
AlertsLock.Unlock()
|
AlertsLock.Unlock()
|
||||||
@@ -136,7 +144,7 @@ func checkStatus() {
|
|||||||
|
|
||||||
for _, alert := range Alerts {
|
for _, alert := range Alerts {
|
||||||
// 跳过未启用
|
// 跳过未启用
|
||||||
if !alert.Enabled() {
|
if alert == nil || !alert.Enabled() || !alert.IsSafeToEvaluate() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, server := range m {
|
for _, server := range m {
|
||||||
@@ -152,6 +160,22 @@ func checkStatus() {
|
|||||||
if alert.UserID != server.GetUserID() && !role.IsAdmin() {
|
if alert.UserID != server.GetUserID() && !role.IsAdmin() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
checkStatusForServer(alert, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStatusForServer isolates each alert/server evaluation. Input validation
|
||||||
|
// and model-level guards handle known malformed states; this recovery boundary
|
||||||
|
// ensures an unexpected evaluator panic cannot terminate the dashboard process
|
||||||
|
// or prevent unrelated servers from being checked on the same tick.
|
||||||
|
func checkStatusForServer(alert *model.AlertRule, server *model.Server) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
log.Printf("NEZHA>> Recovered panic evaluating alert rule %d for server %d: %v", alert.ID, server.ID, recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
alertsStore[alert.ID][server.ID] = append(alertsStore[alert.
|
alertsStore[alert.ID][server.ID] = append(alertsStore[alert.
|
||||||
ID][server.ID], alert.Snapshot(AlertsCycleTransferStatsStore[alert.ID], server, DB))
|
ID][server.ID], alert.Snapshot(AlertsCycleTransferStatsStore[alert.ID], server, DB))
|
||||||
// 发送通知,分为触发报警和恢复通知
|
// 发送通知,分为触发报警和恢复通知
|
||||||
@@ -195,5 +219,3 @@ func checkStatus() {
|
|||||||
alertsStore[alert.ID][server.ID] = samples[len(samples)-window:]
|
alertsStore[alert.ID][server.ID] = samples[len(samples)-window:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -117,3 +117,53 @@ func TestCheckStatus_SampleMemoryBounded(t *testing.T) {
|
|||||||
t.Fatalf("sample capacity grew unbounded: peak cap %d exceeds 4x window %d", peakCap, duration*4)
|
t.Fatalf("sample capacity grew unbounded: peak cap %d exceeds 4x window %d", peakCap, duration*4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckStatusForServerRecoversUnexpectedEvaluatorPanic(t *testing.T) {
|
||||||
|
originalStore := alertsStore
|
||||||
|
originalPrevState := alertsPrevState
|
||||||
|
originalCycleStore := AlertsCycleTransferStatsStore
|
||||||
|
alertsStore = map[uint64]map[uint64][][]bool{}
|
||||||
|
alertsPrevState = map[uint64]map[uint64]uint8{}
|
||||||
|
AlertsCycleTransferStatsStore = map[uint64]*model.CycleTransferStats{}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
alertsStore = originalStore
|
||||||
|
alertsPrevState = originalPrevState
|
||||||
|
AlertsCycleTransferStatsStore = originalCycleStore
|
||||||
|
})
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{ID: 77, UserID: 2},
|
||||||
|
Enable: &enabled,
|
||||||
|
Rules: []*model.Rule{{Type: "cpu", Duration: 3, Cover: model.RuleCoverAll}},
|
||||||
|
}
|
||||||
|
server := &model.Server{Common: model.Common{ID: 1, UserID: 2}}
|
||||||
|
model.InitServer(server)
|
||||||
|
|
||||||
|
// The deliberately missing alertsStore[alert.ID] map panics at assignment
|
||||||
|
// after evaluation. The per-alert/server recovery boundary must contain it.
|
||||||
|
checkStatusForServer(alert, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCycleTransferStatsSkipsPoisonedPersistedRule(t *testing.T) {
|
||||||
|
original := AlertsCycleTransferStatsStore
|
||||||
|
AlertsCycleTransferStatsStore = map[uint64]*model.CycleTransferStats{}
|
||||||
|
t.Cleanup(func() { AlertsCycleTransferStatsStore = original })
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{ID: 88},
|
||||||
|
Enable: &enabled,
|
||||||
|
Rules: []*model.Rule{{
|
||||||
|
Type: "transfer_in_cycle",
|
||||||
|
CycleInterval: 1,
|
||||||
|
Cover: model.RuleCoverAll,
|
||||||
|
// CycleStart intentionally nil, as it may be in a poisoned legacy DB row.
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
addCycleTransferStatsInfo(alert)
|
||||||
|
if _, exists := AlertsCycleTransferStatsStore[alert.ID]; exists {
|
||||||
|
t.Fatal("invalid persisted cycle rule must not be scheduled")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user