Compare commits

...

6 Commits

6 changed files with 146 additions and 48 deletions

1
.gitignore vendored
View File

@@ -44,4 +44,5 @@ logs/
# Temporary files # Temporary files
tmp/ tmp/
temp/ temp/
.worktrees/

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -11,6 +12,8 @@ import (
"git.apinb.com/ops/logs/internal/config" "git.apinb.com/ops/logs/internal/config"
) )
const alertResponseBodyLimit = 1 << 20
// AlertReceiveBody 与 alert ReceiveRequest 对齐(含必填 raw_data // AlertReceiveBody 与 alert ReceiveRequest 对齐(含必填 raw_data
type AlertReceiveBody struct { type AlertReceiveBody struct {
AlertName string `json:"alert_name"` AlertName string `json:"alert_name"`
@@ -53,28 +56,56 @@ func forwardAlert(body AlertReceiveBody) error {
if body.PolicyID == 0 && cfg.DefaultPolicyID > 0 { if body.PolicyID == 0 && cfg.DefaultPolicyID > 0 {
body.PolicyID = cfg.DefaultPolicyID body.PolicyID = cfg.DefaultPolicyID
} }
rawEvent := buildRawEventIngestBody(body, "parsed") raw, err := json.Marshal(body)
raw, err := json.Marshal(rawEvent)
if err != nil { if err != nil {
return err return err
} }
url := cfg.BaseURL + "/Alert/v1/raw-events/ingest" return postAlertPayload(cfg, "/Alert/v1/alerts/receive", raw)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw)) }
func postAlertPayload(cfg *config.AlertForwardConf, path string, payload []byte) error {
req, err := http.NewRequest(http.MethodPost, cfg.BaseURL+path, bytes.NewReader(payload))
if err != nil { if err != nil {
return err return fmt.Errorf("创建 Alert 转发请求失败:%w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if cfg.InternalKey != "" { if cfg.InternalKey != "" {
req.Header.Set("X-Internal-Key", cfg.InternalKey) req.Header.Set("X-Internal-Key", cfg.InternalKey)
} }
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return fmt.Errorf("发送 Alert 转发请求失败:%w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, alertResponseBodyLimit+1))
if err != nil {
return fmt.Errorf("读取 Alert 响应失败:%w", err)
}
if len(responseBody) > alertResponseBodyLimit {
return fmt.Errorf("Alert 响应体超过 %d 字节限制,请稍后重试", alertResponseBodyLimit)
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("alert returned HTTP %d", resp.StatusCode) return fmt.Errorf("Alert 返回 HTTP %d,请稍后重试", resp.StatusCode)
}
var result struct {
Code *int32 `json:"code"`
}
if err := json.Unmarshal(responseBody, &result); err != nil {
return fmt.Errorf("Alert 响应不是有效 JSON%v请稍后重试", err)
}
if result.Code == nil {
return fmt.Errorf("Alert 响应缺少 code无法确认转发成功请稍后重试")
}
if *result.Code != 0 {
return fmt.Errorf("Alert 拒绝转发,业务 code=%d请稍后重试", *result.Code)
} }
return nil return nil
} }

View File

@@ -1,10 +1,8 @@
package ingest package ingest
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"strings" "strings"
"time" "time"
@@ -118,25 +116,7 @@ func forwardRawEvent(body RawEventIngestBody) error {
if err != nil { if err != nil {
return err return err
} }
url := cfg.BaseURL + "/Alert/v1/raw-events/ingest" return postAlertPayload(cfg, "/Alert/v1/raw-events/ingest", raw)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if cfg.InternalKey != "" {
req.Header.Set("X-Internal-Key", cfg.InternalKey)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("alert returned HTTP %d", resp.StatusCode)
}
return nil
} }
func markOutboxRetry(row models.AlertOutbox, msg string) { func markOutboxRetry(row models.AlertOutbox, msg string) {

View File

@@ -498,21 +498,7 @@ func (e *Engine) HandleTrap(addr *net.UDPAddr, pkt *gosnmp.SnmpPacket) {
rules := e.trapRules rules := e.trapRules
e.mu.RUnlock() e.mu.RUnlock()
var matched *models.TrapRule matched := firstMatchingTrapRule(rules, trapOID, fp)
for i := range rules {
if trapRuleMatches(&rules[i], trapOID, fp) {
matched = &rules[i]
break
}
}
if matched == nil && dict != nil && strings.TrimSpace(dict.SeverityCode) != "" {
matched = &models.TrapRule{
AlertName: firstNonEmpty(firstNonEmpty(dict.Name, dict.Title), "SNMP Trap"),
SeverityCode: dict.SeverityCode,
PolicyID: 0,
}
}
if matched == nil { if matched == nil {
rawBytes, mErr := json.Marshal(fp) rawBytes, mErr := json.Marshal(fp)
if mErr != nil { if mErr != nil {
@@ -665,7 +651,7 @@ func trapRuleMatches(rule *models.TrapRule, trapOID, varbindFP string) bool {
if hasOID && !strings.HasPrefix(normOID(trapOID), normOID(rule.OIDPrefix)) { if hasOID && !strings.HasPrefix(normOID(trapOID), normOID(rule.OIDPrefix)) {
return false return false
} }
if rule.VarbindMatchRegex != "" { if hasRE {
re, err := regexp.Compile(rule.VarbindMatchRegex) re, err := regexp.Compile(rule.VarbindMatchRegex)
if err != nil { if err != nil {
return false return false
@@ -677,6 +663,15 @@ func trapRuleMatches(rule *models.TrapRule, trapOID, varbindFP string) bool {
return true return true
} }
func firstMatchingTrapRule(rules []models.TrapRule, trapOID, varbindFP string) *models.TrapRule {
for i := range rules {
if trapRuleMatches(&rules[i], trapOID, varbindFP) {
return &rules[i]
}
}
return nil
}
func firstNonEmpty(a, b string) string { func firstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" { if strings.TrimSpace(a) != "" {
return a return a

View File

@@ -35,6 +35,10 @@ func CreateSyslogRule(ctx *gin.Context) {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
} }
if err := validateSyslogRule(&row); err != nil {
infra.Response.Error(ctx, err)
return
}
row.ID = 0 row.ID = 0
if err := impl.DBService.Create(&row).Error; err != nil { if err := impl.DBService.Create(&row).Error; err != nil {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
@@ -60,6 +64,10 @@ func UpdateSyslogRule(ctx *gin.Context) {
return return
} }
row.ID = id row.ID = id
if err := validateSyslogRule(&row); err != nil {
infra.Response.Error(ctx, err)
return
}
if err := impl.DBService.Save(&row).Error; err != nil { if err := impl.DBService.Save(&row).Error; err != nil {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
@@ -98,6 +106,10 @@ func CreateTrapRule(ctx *gin.Context) {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
} }
if err := validateTrapRule(&row); err != nil {
infra.Response.Error(ctx, err)
return
}
row.ID = 0 row.ID = 0
if err := impl.DBService.Create(&row).Error; err != nil { if err := impl.DBService.Create(&row).Error; err != nil {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
@@ -123,6 +135,10 @@ func UpdateTrapRule(ctx *gin.Context) {
return return
} }
row.ID = id row.ID = id
if err := validateTrapRule(&row); err != nil {
infra.Response.Error(ctx, err)
return
}
if err := impl.DBService.Save(&row).Error; err != nil { if err := impl.DBService.Save(&row).Error; err != nil {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return

View File

@@ -0,0 +1,75 @@
package controllers
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"git.apinb.com/ops/logs/internal/models"
)
func validateSyslogRule(rule *models.SyslogRule) error {
regexFields := []struct {
name string
pattern string
}{
{name: "keyword_regex", pattern: rule.KeywordRegex},
{name: "message_regex", pattern: rule.MessageRegex},
{name: "resource_uid_extract_regex", pattern: rule.ResourceUIDExtractRegex},
}
for _, field := range regexFields {
if err := validateOptionalRegex(field.name, field.pattern); err != nil {
return err
}
}
if raw := strings.TrimSpace(rule.SeverityMappingJSON); raw != "" {
var mapping map[string]any
if err := json.Unmarshal([]byte(raw), &mapping); err != nil {
return fmt.Errorf("severity_mapping_json 必须是字符串到字符串的 JSON 对象:%v请修正 severity_mapping_json 后重试", err)
}
if mapping == nil {
return fmt.Errorf("severity_mapping_json 必须是字符串到字符串的 JSON 对象,不能是 null请改为 JSON 对象后重试")
}
for pattern, severity := range mapping {
if _, ok := severity.(string); !ok {
return fmt.Errorf("severity_mapping_json 的映射键 %q 对应值必须是字符串;请修正该映射值后重试", pattern)
}
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("severity_mapping_json 的映射键 %q 不是有效正则表达式:%v请修正该映射键后重试", pattern, err)
}
}
}
if strings.TrimSpace(rule.DeviceNameContains) == "" &&
strings.TrimSpace(rule.SourceMatch) == "" &&
strings.TrimSpace(rule.KeywordRegex) == "" &&
strings.TrimSpace(rule.MessageRegex) == "" {
return fmt.Errorf("Syslog 规则的匹配条件全部为空,运行时永远不会命中;请至少填写 device_name_contains、source_match、keyword_regex、message_regex 中的一项")
}
return nil
}
func validateTrapRule(rule *models.TrapRule) error {
if strings.TrimSpace(rule.VarbindMatchRegex) != "" {
if _, err := regexp.Compile(rule.VarbindMatchRegex); err != nil {
return fmt.Errorf("varbind_match_regex 不是有效正则表达式:%v请修正 varbind_match_regex 后重试", err)
}
}
if strings.TrimSpace(rule.OIDPrefix) == "" && strings.TrimSpace(rule.VarbindMatchRegex) == "" {
return fmt.Errorf("Trap 规则的匹配条件全部为空,运行时永远不会命中;请至少填写 oid_prefix、varbind_match_regex 中的一项")
}
return nil
}
func validateOptionalRegex(field, pattern string) error {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return nil
}
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("%s 不是有效正则表达式:%v请修正 %s 后重试", field, err, field)
}
return nil
}