发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
// 告警规则约束与命中记录保留期(窗口计数之外多留几天便于排查)。
|
||||
const (
|
||||
alertMaxThreshold = 100
|
||||
alertMaxWindowMin = 1440
|
||||
alertHitRetention = 7 * 24 * time.Hour
|
||||
alertSourceIPIn = "in"
|
||||
alertSourceIPNotIn = "notin"
|
||||
)
|
||||
|
||||
// ErrInvalidAlertRule 标记规则字段非法,api 层映射 400。
|
||||
var ErrInvalidAlertRule = fmt.Errorf("告警规则字段非法")
|
||||
|
||||
// ListAlertRules 返回全部告警规则(创建顺序)。
|
||||
func (s *LogEventService) ListAlertRules(ctx context.Context) ([]model.AlertRule, error) {
|
||||
var rules []model.AlertRule
|
||||
if err := s.db.WithContext(ctx).Order("id").Find(&rules).Error; err != nil {
|
||||
return nil, fmt.Errorf("list alert rules: %w", err)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// CreateAlertRule 校验并创建规则。
|
||||
func (s *LogEventService) CreateAlertRule(ctx context.Context, rule model.AlertRule) (model.AlertRule, error) {
|
||||
if err := validateAlertRule(&rule); err != nil {
|
||||
return model.AlertRule{}, err
|
||||
}
|
||||
rule.ID = 0
|
||||
if err := s.db.WithContext(ctx).Create(&rule).Error; err != nil {
|
||||
return model.AlertRule{}, fmt.Errorf("create alert rule: %w", err)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// UpdateAlertRule 校验并整体覆盖规则(含启停)。
|
||||
func (s *LogEventService) UpdateAlertRule(ctx context.Context, id uint, rule model.AlertRule) (model.AlertRule, error) {
|
||||
if err := validateAlertRule(&rule); err != nil {
|
||||
return model.AlertRule{}, err
|
||||
}
|
||||
var cur model.AlertRule
|
||||
if err := s.db.WithContext(ctx).First(&cur, id).Error; err != nil {
|
||||
return model.AlertRule{}, fmt.Errorf("find alert rule %d: %w", id, err)
|
||||
}
|
||||
rule.ID, rule.CreatedAt = cur.ID, cur.CreatedAt
|
||||
if err := s.db.WithContext(ctx).Save(&rule).Error; err != nil {
|
||||
return model.AlertRule{}, fmt.Errorf("update alert rule: %w", err)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// DeleteAlertRule 删除规则及其命中记录。
|
||||
func (s *LogEventService) DeleteAlertRule(ctx context.Context, id uint) error {
|
||||
if err := s.db.WithContext(ctx).Delete(&model.AlertRule{}, id).Error; err != nil {
|
||||
return fmt.Errorf("delete alert rule: %w", err)
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Where("rule_id = ?", id).Delete(&model.AlertRuleHit{}).Error; err != nil {
|
||||
return fmt.Errorf("delete alert rule hits: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAlertRule 校验字段并归一化;非法时返回含具体原因的 ErrInvalidAlertRule 包装。
|
||||
func validateAlertRule(rule *model.AlertRule) error {
|
||||
rule.Name = strings.TrimSpace(rule.Name)
|
||||
if rule.Name == "" {
|
||||
return fmt.Errorf("%w: 名称必填", ErrInvalidAlertRule)
|
||||
}
|
||||
if rule.SourceIPMode == "" {
|
||||
rule.SourceIPMode = alertSourceIPIn
|
||||
}
|
||||
if rule.SourceIPMode != alertSourceIPIn && rule.SourceIPMode != alertSourceIPNotIn {
|
||||
return fmt.Errorf("%w: 来源 IP 模式须为 in/notin", ErrInvalidAlertRule)
|
||||
}
|
||||
if rule.Threshold < 1 || rule.Threshold > alertMaxThreshold {
|
||||
return fmt.Errorf("%w: 阈值须在 1-%d 之间", ErrInvalidAlertRule, alertMaxThreshold)
|
||||
}
|
||||
if rule.Threshold > 1 && (rule.WindowMinutes < 1 || rule.WindowMinutes > alertMaxWindowMin) {
|
||||
return fmt.Errorf("%w: 阈值>1 时窗口须在 1-%d 分钟之间", ErrInvalidAlertRule, alertMaxWindowMin)
|
||||
}
|
||||
if rule.EventTypes != "" {
|
||||
rule.EventTypes = normalizeCSV(rule.EventTypes)
|
||||
}
|
||||
return validateAlertRuleIPs(rule)
|
||||
}
|
||||
|
||||
// validateAlertRuleIPs 归一化并校验来源 IP 列表(裸 IP 或 CIDR)。
|
||||
func validateAlertRuleIPs(rule *model.AlertRule) error {
|
||||
if rule.SourceIPs == "" {
|
||||
return nil
|
||||
}
|
||||
rule.SourceIPs = normalizeCSV(rule.SourceIPs)
|
||||
for _, item := range strings.Split(rule.SourceIPs, ",") {
|
||||
if _, err := parseIPMatcher(item); err != nil {
|
||||
return fmt.Errorf("%w: 来源 IP %q 不是合法的 IP 或 CIDR", ErrInvalidAlertRule, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeCSV 去除各项空白与空项后重组逗号分隔串。
|
||||
func normalizeCSV(s string) string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// parseIPMatcher 把裸 IP 或 CIDR 解析为前缀(裸 IP 视为单地址前缀)。
|
||||
func parseIPMatcher(item string) (netip.Prefix, error) {
|
||||
if strings.Contains(item, "/") {
|
||||
return netip.ParsePrefix(item)
|
||||
}
|
||||
addr, err := netip.ParseAddr(item)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, err
|
||||
}
|
||||
return netip.PrefixFrom(addr, addr.BitLen()), nil
|
||||
}
|
||||
|
||||
// ipListMatch 报告 ip 是否命中列表中的任一前缀;ip 解析失败视为未命中。
|
||||
func ipListMatch(list, ip string) bool {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range strings.Split(list, ",") {
|
||||
if p, err := parseIPMatcher(item); err == nil && p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ruleHits 报告事件是否命中规则的全部条件(AND 语义,空条件视为任意)。
|
||||
func ruleHits(rule model.AlertRule, e *model.LogEvent, p parsedEvent) bool {
|
||||
if rule.OciConfigID != 0 && rule.OciConfigID != e.OciConfigID {
|
||||
return false
|
||||
}
|
||||
name := relayEventShortName(p.EventType)
|
||||
if rule.EventTypes != "" && !slices.Contains(strings.Split(rule.EventTypes, ","), name) {
|
||||
return false
|
||||
}
|
||||
if rule.ResourceMatch != "" && !strings.Contains(p.ResourceName, rule.ResourceMatch) {
|
||||
return false
|
||||
}
|
||||
return ruleIPHits(rule, p.SourceIP)
|
||||
}
|
||||
|
||||
// ruleIPHits 按模式判定来源 IP 条件:in 命中列表告警;notin 不在列表才告警,
|
||||
// 事件缺 IP 字段时 notin 不告警(避免解析缺字段导致白名单误报)。
|
||||
func ruleIPHits(rule model.AlertRule, ip string) bool {
|
||||
if rule.SourceIPs == "" {
|
||||
return true
|
||||
}
|
||||
if rule.SourceIPMode == alertSourceIPNotIn {
|
||||
return ip != "" && !ipListMatch(rule.SourceIPs, ip)
|
||||
}
|
||||
return ipListMatch(rule.SourceIPs, ip)
|
||||
}
|
||||
|
||||
// matchAlertRules 对一条已解析事件执行全部启用规则;任何内部错误只记日志,不影响解析主流程。
|
||||
func (s *LogEventService) matchAlertRules(ctx context.Context, rules []model.AlertRule, e *model.LogEvent, p parsedEvent) {
|
||||
if s.notifier == nil {
|
||||
return
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled || !ruleHits(rule, e, p) {
|
||||
continue
|
||||
}
|
||||
count, ok := s.recordAlertHit(ctx, rule, e)
|
||||
if !ok || count < rule.Threshold || !s.alertCooldownPass(rule) {
|
||||
continue
|
||||
}
|
||||
s.notifier.SendTemplateAsync("audit_alert", map[string]string{
|
||||
"rule": rule.Name, "tenant": s.configAlias(ctx, e.OciConfigID),
|
||||
"event": relayEventShortName(p.EventType), "resource": p.ResourceName,
|
||||
"ip": p.SourceIP, "count": fmt.Sprint(count),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// recordAlertHit 落一条命中并返回窗口内累计次数;阈值 1 的规则免计数直接触发。
|
||||
func (s *LogEventService) recordAlertHit(ctx context.Context, rule model.AlertRule, e *model.LogEvent) (int, bool) {
|
||||
count, err := s.recordAlertHitTx(ctx, rule, e)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Printf("alert rule hit record: %v", err)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return count, true
|
||||
}
|
||||
|
||||
func (s *LogEventService) recordAlertHitTx(ctx context.Context, rule model.AlertRule, event *model.LogEvent) (int, error) {
|
||||
count := 0
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
count, err = insertAlertHit(tx, rule, event)
|
||||
return err
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// insertAlertHit 按 rule→event 锁顺序确认引用存在后插入并统计窗口命中。
|
||||
func insertAlertHit(tx *gorm.DB, rule model.AlertRule, event *model.LogEvent) (int, error) {
|
||||
if err := lockAlertRefs(tx, rule.ID, event.ID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if rule.Threshold <= 1 {
|
||||
return 1, nil
|
||||
}
|
||||
now := time.Now()
|
||||
hit := model.AlertRuleHit{RuleID: rule.ID, LogEventID: event.ID, HitAt: now}
|
||||
if err := tx.Create(&hit).Error; err != nil {
|
||||
return 0, fmt.Errorf("create alert rule hit: %w", err)
|
||||
}
|
||||
var count int64
|
||||
cutoff := now.Add(-time.Duration(rule.WindowMinutes) * time.Minute)
|
||||
err := tx.Model(&model.AlertRuleHit{}).
|
||||
Where("rule_id = ? AND hit_at >= ?", rule.ID, cutoff).Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count alert rule hits: %w", err)
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
func lockAlertRefs(tx *gorm.DB, ruleID, eventID uint) error {
|
||||
var rule model.AlertRule
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&rule, ruleID).Error; err != nil {
|
||||
return fmt.Errorf("lock alert rule %d: %w", ruleID, err)
|
||||
}
|
||||
var event model.LogEvent
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&event, eventID).Error; err != nil {
|
||||
return fmt.Errorf("lock log event %d: %w", eventID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// alertCooldownPass 报告规则是否已过冷却窗口;通过即记录本次发送时刻。
|
||||
// 阈值 1 的规则无冷却(每次命中即时告警,与既有云端事件通知一致)。
|
||||
func (s *LogEventService) alertCooldownPass(rule model.AlertRule) bool {
|
||||
if rule.Threshold <= 1 {
|
||||
return true
|
||||
}
|
||||
s.alertMu.Lock()
|
||||
defer s.alertMu.Unlock()
|
||||
window := time.Duration(rule.WindowMinutes) * time.Minute
|
||||
if last, ok := s.alertSentAt[rule.ID]; ok && time.Since(last) < window {
|
||||
return false
|
||||
}
|
||||
if s.alertSentAt == nil {
|
||||
s.alertSentAt = map[uint]time.Time{}
|
||||
}
|
||||
s.alertSentAt[rule.ID] = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
// ClearAlertCooldown 清除已删除租户规则的进程内冷却状态。
|
||||
func (s *LogEventService) ClearAlertCooldown(ruleIDs []uint) {
|
||||
s.alertMu.Lock()
|
||||
defer s.alertMu.Unlock()
|
||||
for _, id := range ruleIDs {
|
||||
delete(s.alertSentAt, id)
|
||||
}
|
||||
}
|
||||
|
||||
// loadEnabledAlertRules 载入启用中的规则;失败时返回空集并记日志(解析主流程照常)。
|
||||
func (s *LogEventService) loadEnabledAlertRules(ctx context.Context) []model.AlertRule {
|
||||
var rules []model.AlertRule
|
||||
err := s.db.WithContext(ctx).Where("enabled = ?", true).Order("id").Find(&rules).Error
|
||||
if err != nil {
|
||||
log.Printf("load alert rules: %v", err)
|
||||
return nil
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// cleanupAlertHits 删除保留期外的命中记录(随 cleanupOnce 周期执行)。
|
||||
func (s *LogEventService) cleanupAlertHits(ctx context.Context) {
|
||||
cutoff := time.Now().Add(-alertHitRetention)
|
||||
if err := s.db.WithContext(ctx).Where("hit_at < ?", cutoff).Delete(&model.AlertRuleHit{}).Error; err != nil {
|
||||
log.Printf("cleanup alert hits: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user