diff --git a/doc/Syslog-Trap接入与重放.md b/doc/Syslog-Trap接入与重放.md new file mode 100644 index 0000000..f3bdc23 --- /dev/null +++ b/doc/Syslog-Trap接入与重放.md @@ -0,0 +1,87 @@ +# Syslog-Trap 接入与重放 + +## 接入目标 + +`logs` 服务负责接收 Syslog 与 SNMP Trap,按字典和规则解析后写入 `logs_events`,并通过 `logs_alert_outbox` 异步转发到 `alert` 的原始事件池: + +```text +Syslog / Trap -> logs_events -> logs_alert_outbox -> Alert/v1/raw-events/ingest +``` + +转发使用 `X-Internal-Key`,配置来自 `AlertForward.internal_key`。解析成功的事件 `parse_status=parsed`,未命中字典或规则的事件仍保存原始报文,并以 `parse_status=unparsed` 入队,便于规则调整后重放。 + +## 部署配置 + +`logs` 当前内置 UDP 接收器: + +```yaml +Ingest: + syslog_listen_addr: "0.0.0.0:5140" + trap_listen_addr: "0.0.0.0:1620" + rule_refresh_secs: 30 + +AlertForward: + enabled: true + base_url: "http://127.0.0.1:18080" + internal_key: "change-me" + default_policy_id: 1 +``` + +生产环境如需标准端口 `514/162`,建议由 systemd socket、firewalld rich rule、iptables REDIRECT 或外层采集网关转发到非特权端口。TCP Syslog 接入建议在网关层启用 TCP listener,再转发到 UDP 或调用后续 HTTP ingest 入口;开启 TCP 时必须保留原始来源 IP 和 trace ID。 + +## 字典与规则 + +Trap 字典字段: + +- `vendor`:厂商,例如 `H3C`。 +- `oid`:精确 Trap OID。 +- `oid_prefix`:OID 前缀,兼容旧字典。 +- `name` / `title`:展示名称。 +- `severity_mapping_json`:级别映射 JSON。 +- `parse_expression`:解析 varbind 的表达式或正则。 + +Syslog 规则字段: + +- `source_match`:来源 IP、主机名或原始行子串。 +- `message_regex`:消息正文正则。 +- `severity_mapping_json`:按正则映射平台级别。 +- `resource_uid_extract_regex`:提取 `resource_uid`,优先使用命名分组 `resource_uid`。 + +示例 Syslog: + +```text +<189>Jun 24 10:00:01 h3c-core-01 IFNET/4/LINK_DOWN: Interface GigabitEthernet1/0/1 is down, resource_uid=network:h3c-core-01 +``` + +示例 H3C Trap OID: + +```text +1.3.6.1.6.3.1.1.5.3 +``` + +## 未解析队列与重放 + +未解析事件仍写入 `logs_events`,并创建 outbox payload: + +- `source_type=syslog` 或 `trap` +- `parse_status=unparsed` +- `raw_payload` 保存原始报文或 varbind 摘要 + +重放接口: + +```http +POST /Logs/v1/entries/{id}/replay +Authorization: Bearer +``` + +成功响应会返回新的 `outbox_id`。重放 payload 使用 `parse_status=replayed`,并带上 `labels.replay_of_log_event_id`,前端可在“日志查询 -> 重放结果”中查看发送结果,失败任务可人工重试。 + +## Smoke 样例 + +输出 H3C Syslog 与 Trap 示例载荷: + +```powershell +C:\Users\27105\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe scripts\test_alert_receive_smoke.py --print-log-samples +``` + +这些样例用于准备 UDP/TCP 接收器 smoke 数据,也可作为联调 alert 原始事件池时的期望字段参考。 diff --git a/internal/ingest/alert_forward.go b/internal/ingest/alert_forward.go index a420446..187c530 100644 --- a/internal/ingest/alert_forward.go +++ b/internal/ingest/alert_forward.go @@ -1,64 +1,139 @@ -package ingest - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "time" - - "git.apinb.com/ops/logs/internal/config" -) - -// AlertReceiveBody 与 alert ReceiveRequest 对齐(含必填 raw_data) -type AlertReceiveBody struct { - AlertName string `json:"alert_name"` - Summary string `json:"summary"` - Description string `json:"description"` - SeverityCode string `json:"severity_code"` - Value string `json:"value"` - Threshold string `json:"threshold"` - Labels map[string]string `json:"labels"` - Agent string `json:"agent"` - PolicyID uint `json:"policy_id"` - RawData json.RawMessage `json:"raw_data"` -} - -func forwardAlert(body AlertReceiveBody) error { - cfg := config.Spec.AlertForward - if cfg == nil || !cfg.Enabled || cfg.BaseURL == "" { - return nil - } - if len(body.RawData) == 0 { - return fmt.Errorf("raw_data 不能为空") - } - if body.AlertName == "" { - body.AlertName = "日志告警" - } - if body.PolicyID == 0 && cfg.DefaultPolicyID > 0 { - body.PolicyID = cfg.DefaultPolicyID - } - raw, err := json.Marshal(body) - if err != nil { - return err - } - url := cfg.BaseURL + "/Alert/v1/alerts/receive" - 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 -} +package ingest + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "git.apinb.com/ops/logs/internal/config" +) + +// AlertReceiveBody 与 alert ReceiveRequest 对齐(含必填 raw_data) +type AlertReceiveBody struct { + AlertName string `json:"alert_name"` + Summary string `json:"summary"` + Description string `json:"description"` + SeverityCode string `json:"severity_code"` + Value string `json:"value"` + Threshold string `json:"threshold"` + Labels map[string]string `json:"labels"` + Agent string `json:"agent"` + PolicyID uint `json:"policy_id"` + RawData json.RawMessage `json:"raw_data"` +} + +type RawEventIngestBody struct { + SourceType string `json:"source_type"` + ResourceUID string `json:"resource_uid,omitempty"` + EventTime time.Time `json:"event_time"` + Severity string `json:"severity"` + Title string `json:"title"` + Message string `json:"message"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + ParseStatus string `json:"parse_status"` + RawPayload json.RawMessage `json:"raw_payload"` + TraceID string `json:"trace_id,omitempty"` +} + +func forwardAlert(body AlertReceiveBody) error { + cfg := config.Spec.AlertForward + if cfg == nil || !cfg.Enabled || cfg.BaseURL == "" { + return nil + } + if len(body.RawData) == 0 { + return fmt.Errorf("raw_data 不能为空") + } + if body.AlertName == "" { + body.AlertName = "日志告警" + } + if body.PolicyID == 0 && cfg.DefaultPolicyID > 0 { + body.PolicyID = cfg.DefaultPolicyID + } + rawEvent := buildRawEventIngestBody(body, "parsed") + raw, err := json.Marshal(rawEvent) + if err != nil { + return err + } + url := cfg.BaseURL + "/Alert/v1/raw-events/ingest" + 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 buildRawEventIngestBody(body AlertReceiveBody, parseStatus string) RawEventIngestBody { + sourceType := rawEventSourceType(body) + if parseStatus == "" { + parseStatus = "parsed" + } + annotations := map[string]string{ + "description": body.Description, + "value": body.Value, + "threshold": body.Threshold, + "agent": body.Agent, + } + return RawEventIngestBody{ + SourceType: sourceType, + ResourceUID: rawEventResourceUID(body.Labels), + EventTime: time.Now().UTC(), + Severity: body.SeverityCode, + Title: firstNonEmpty(body.AlertName, "日志事件"), + Message: firstNonEmpty(body.Summary, body.Description), + Labels: body.Labels, + Annotations: annotations, + ParseStatus: parseStatus, + RawPayload: body.RawData, + } +} + +func rawEventSourceType(body AlertReceiveBody) string { + if body.Labels != nil { + switch strings.TrimSpace(body.Labels["source_subtype"]) { + case "syslog": + return "syslog" + case "snmp_trap": + return "trap" + } + } + switch body.Agent { + case "logs-syslog": + return "syslog" + case "logs-trap": + return "trap" + default: + return "syslog" + } +} + +func rawEventResourceUID(labels map[string]string) string { + if labels == nil { + return "" + } + if uid := strings.TrimSpace(labels["resource_uid"]); uid != "" { + return uid + } + category := strings.TrimSpace(labels["resource_category"]) + identity := strings.TrimSpace(labels["service_identity"]) + if category != "" && identity != "" { + return category + ":" + identity + } + return "" +} diff --git a/internal/ingest/alert_forward_test.go b/internal/ingest/alert_forward_test.go new file mode 100644 index 0000000..155cc00 --- /dev/null +++ b/internal/ingest/alert_forward_test.go @@ -0,0 +1,59 @@ +package ingest + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.apinb.com/ops/logs/internal/config" +) + +func TestForwardAlertPostsRawEventIngestPayload(t *testing.T) { + var gotPath string + var gotBody RawEventIngestBody + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + config.Spec.AlertForward = &config.AlertForwardConf{ + Enabled: true, + BaseURL: server.URL, + InternalKey: "internal-key", + } + + err := forwardAlert(AlertReceiveBody{ + AlertName: "H3C Trap", + Summary: "Interface down", + Description: "Interface down", + SeverityCode: "major", + Labels: map[string]string{ + "source_subtype": "snmp_trap", + "ip": "192.168.1.10", + }, + Agent: "logs-trap", + RawData: json.RawMessage(`{"trap_oid":"1.3.6.1.4.1"}`), + }) + if err != nil { + t.Fatalf("forwardAlert returned error: %v", err) + } + + if gotPath != "/Alert/v1/raw-events/ingest" { + t.Fatalf("expected raw event ingest path, got %q", gotPath) + } + if gotBody.SourceType != "trap" { + t.Fatalf("expected trap source type, got %q", gotBody.SourceType) + } + if gotBody.ParseStatus != "parsed" { + t.Fatalf("expected parsed status, got %q", gotBody.ParseStatus) + } + if string(gotBody.RawPayload) != `{"trap_oid":"1.3.6.1.4.1"}` { + t.Fatalf("raw payload changed: %s", string(gotBody.RawPayload)) + } +} diff --git a/internal/ingest/alert_outbox.go b/internal/ingest/alert_outbox.go index b37729e..a00e5bf 100644 --- a/internal/ingest/alert_outbox.go +++ b/internal/ingest/alert_outbox.go @@ -1,125 +1,181 @@ -package ingest - -import ( - "encoding/json" - "strings" - "time" - - "git.apinb.com/ops/logs/internal/impl" - "git.apinb.com/ops/logs/internal/models" -) - -const ( - outboxStatusPending = "pending" - outboxStatusRetrying = "retrying" - outboxStatusSent = "sent" - outboxStatusDead = "dead" -) - -func enqueueAlert(logEventID uint, body AlertReceiveBody) error { - payload, err := json.Marshal(body) - if err != nil { - return err - } - row := models.AlertOutbox{ - LogEventID: logEventID, - PayloadJSON: string(payload), - Status: outboxStatusPending, - RetryCount: 0, - NextRetryAt: time.Now(), - LastError: "", - } - return impl.DBService.Create(&row).Error -} - -func StartAlertDispatcher() { - go func() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for range ticker.C { - processAlertOutboxBatch(20) - } - }() -} - -func processAlertOutboxBatch(limit int) { - if limit <= 0 { - limit = 20 - } - var rows []models.AlertOutbox - now := time.Now() - err := impl.DBService. - Where("status IN ? AND next_retry_at <= ?", []string{outboxStatusPending, outboxStatusRetrying}, now). - Order("id asc"). - Limit(limit). - Find(&rows).Error - if err != nil || len(rows) == 0 { - return - } - for _, row := range rows { - processOneOutbox(row) - } -} - -func processOneOutbox(row models.AlertOutbox) { - var body AlertReceiveBody - if err := json.Unmarshal([]byte(row.PayloadJSON), &body); err != nil { - markOutboxDead(row.ID, row.RetryCount, "invalid_payload: "+err.Error()) - return - } - if err := forwardAlert(body); err != nil { - markOutboxRetry(row, err.Error()) - return - } - _ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", row.ID).Updates(map[string]interface{}{ - "status": outboxStatusSent, - "last_error": "", - "next_retry_at": time.Now(), - }).Error - _ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Updates(map[string]interface{}{ - "alert_sent": true, - "dispatch_status": "sent", - }).Error -} - -func markOutboxRetry(row models.AlertOutbox, msg string) { - retry := row.RetryCount + 1 - const maxRetry = 5 - if retry > maxRetry { - markOutboxDead(row.ID, retry, msg) - return - } - backoff := time.Duration(retry*retry) * time.Second - if backoff > 60*time.Second { - backoff = 60 * time.Second - } - _ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", row.ID).Updates(map[string]interface{}{ - "status": outboxStatusRetrying, - "retry_count": retry, - "next_retry_at": time.Now().Add(backoff), - "last_error": truncateError(msg, 1024), - }).Error - _ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Update("dispatch_status", "retrying").Error -} - -func markOutboxDead(id uint, retry int, msg string) { - _ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{ - "status": outboxStatusDead, - "retry_count": retry, - "next_retry_at": time.Now(), - "last_error": truncateError(msg, 1024), - }).Error - var row models.AlertOutbox - if err := impl.DBService.Select("log_event_id").First(&row, id).Error; err == nil && row.LogEventID > 0 { - _ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Update("dispatch_status", "dead").Error - } -} - -func truncateError(s string, n int) string { - s = strings.TrimSpace(s) - if len(s) <= n { - return s - } - return s[:n] -} - +package ingest + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "git.apinb.com/ops/logs/internal/config" + "git.apinb.com/ops/logs/internal/impl" + "git.apinb.com/ops/logs/internal/models" +) + +const ( + outboxStatusPending = "pending" + outboxStatusRetrying = "retrying" + outboxStatusSent = "sent" + outboxStatusDead = "dead" +) + +func enqueueAlert(logEventID uint, body AlertReceiveBody) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + return enqueuePayload(logEventID, string(payload)) +} + +func enqueueRawEvent(logEventID uint, body AlertReceiveBody, parseStatus string) error { + payload, err := json.Marshal(buildRawEventIngestBody(body, parseStatus)) + if err != nil { + return err + } + return enqueuePayload(logEventID, string(payload)) +} + +func enqueuePayload(logEventID uint, payloadJSON string) error { + row := models.AlertOutbox{ + LogEventID: logEventID, + PayloadJSON: payloadJSON, + Status: outboxStatusPending, + RetryCount: 0, + NextRetryAt: time.Now(), + LastError: "", + } + return impl.DBService.Create(&row).Error +} + +func StartAlertDispatcher() { + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for range ticker.C { + processAlertOutboxBatch(20) + } + }() +} + +func processAlertOutboxBatch(limit int) { + if limit <= 0 { + limit = 20 + } + var rows []models.AlertOutbox + now := time.Now() + err := impl.DBService. + Where("status IN ? AND next_retry_at <= ?", []string{outboxStatusPending, outboxStatusRetrying}, now). + Order("id asc"). + Limit(limit). + Find(&rows).Error + if err != nil || len(rows) == 0 { + return + } + for _, row := range rows { + processOneOutbox(row) + } +} + +func processOneOutbox(row models.AlertOutbox) { + var body AlertReceiveBody + if err := json.Unmarshal([]byte(row.PayloadJSON), &body); err != nil { + markOutboxDead(row.ID, row.RetryCount, "invalid_payload: "+err.Error()) + return + } + if err := forwardOutboxPayload(row.PayloadJSON, body); err != nil { + markOutboxRetry(row, err.Error()) + return + } + _ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", row.ID).Updates(map[string]interface{}{ + "status": outboxStatusSent, + "last_error": "", + "next_retry_at": time.Now(), + }).Error + _ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Updates(map[string]interface{}{ + "alert_sent": true, + "dispatch_status": "sent", + }).Error +} + +func forwardOutboxPayload(payloadJSON string, legacyBody AlertReceiveBody) error { + var rawEvent RawEventIngestBody + if err := json.Unmarshal([]byte(payloadJSON), &rawEvent); err == nil && rawEvent.SourceType != "" && len(rawEvent.RawPayload) > 0 { + return forwardRawEvent(rawEvent) + } + return forwardAlert(legacyBody) +} + +func forwardRawEvent(body RawEventIngestBody) error { + cfg := config.Spec.AlertForward + if cfg == nil || !cfg.Enabled || cfg.BaseURL == "" { + return nil + } + if len(body.RawPayload) == 0 { + return fmt.Errorf("raw_payload 不能为空") + } + raw, err := json.Marshal(body) + if err != nil { + return err + } + url := cfg.BaseURL + "/Alert/v1/raw-events/ingest" + 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) { + retry := row.RetryCount + 1 + const maxRetry = 5 + if retry > maxRetry { + markOutboxDead(row.ID, retry, msg) + return + } + backoff := time.Duration(retry*retry) * time.Second + if backoff > 60*time.Second { + backoff = 60 * time.Second + } + _ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", row.ID).Updates(map[string]interface{}{ + "status": outboxStatusRetrying, + "retry_count": retry, + "next_retry_at": time.Now().Add(backoff), + "last_error": truncateError(msg, 1024), + }).Error + _ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Update("dispatch_status", "retrying").Error +} + +func markOutboxDead(id uint, retry int, msg string) { + _ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{ + "status": outboxStatusDead, + "retry_count": retry, + "next_retry_at": time.Now(), + "last_error": truncateError(msg, 1024), + }).Error + var row models.AlertOutbox + if err := impl.DBService.Select("log_event_id").First(&row, id).Error; err == nil && row.LogEventID > 0 { + _ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Update("dispatch_status", "dead").Error + } +} + +func truncateError(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/internal/ingest/engine.go b/internal/ingest/engine.go index 0c1573d..f3fa7bc 100644 --- a/internal/ingest/engine.go +++ b/internal/ingest/engine.go @@ -1,529 +1,700 @@ -package ingest - -import ( - "encoding/json" - "fmt" - "net" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "time" - - "git.apinb.com/ops/logs/internal/config" - "git.apinb.com/ops/logs/internal/impl" - "git.apinb.com/ops/logs/internal/models" - "github.com/gosnmp/gosnmp" -) - -type Engine struct { - mu sync.RWMutex - - trapDict []models.TrapDictionaryEntry - syslogRules []models.SyslogRule - trapRules []models.TrapRule - shields []models.TrapShield - resourceByIP map[string]resourceRef - resourceByHN map[string]resourceRef -} - -type resourceRef struct { - ResourceType string - ResourceID string - ResourceName string -} - -func resourceTypePriority(resourceType string) int { - switch strings.ToLower(strings.TrimSpace(resourceType)) { - case "server": - return 3 - case "collector": - return 2 - case "device": - return 1 - default: - return 0 - } -} - -var Global = &Engine{} - -func (e *Engine) Refresh() error { - var dict []models.TrapDictionaryEntry - var syslog []models.SyslogRule - var trap []models.TrapRule - var shield []models.TrapShield - var mappings []models.ResourceMapping - - if err := impl.DBService.Where("enabled = ?", true).Find(&dict).Error; err != nil { - return err - } - sort.Slice(dict, func(i, j int) bool { - return len(dict[i].OIDPrefix) > len(dict[j].OIDPrefix) - }) - - if err := impl.DBService.Where("enabled = ?", true).Find(&syslog).Error; err != nil { - return err - } - sort.Slice(syslog, func(i, j int) bool { return syslog[i].Priority > syslog[j].Priority }) - - if err := impl.DBService.Where("enabled = ?", true).Find(&trap).Error; err != nil { - return err - } - sort.Slice(trap, func(i, j int) bool { return trap[i].Priority > trap[j].Priority }) - - if err := impl.DBService.Where("enabled = ?", true).Find(&shield).Error; err != nil { - return err - } - if err := impl.DBService.Where("is_deleted = ?", false).Order("updated_at desc, id desc").Find(&mappings).Error; err != nil { - return err - } - - ipMap := make(map[string]resourceRef) - hnMap := make(map[string]resourceRef) - for _, m := range mappings { - ref := resourceRef{ - ResourceType: m.ResourceType, - ResourceID: m.ResourceID, - ResourceName: m.ResourceName, - } - var ips []string - if err := json.Unmarshal([]byte(m.IPsJSON), &ips); err == nil { - for _, ip := range ips { - key := strings.TrimSpace(ip) - if key == "" { - continue - } - if cur, exists := ipMap[key]; !exists || resourceTypePriority(ref.ResourceType) > resourceTypePriority(cur.ResourceType) { - ipMap[key] = ref - } - } - } - var hostnames []string - if err := json.Unmarshal([]byte(m.HostnamesJSON), &hostnames); err == nil { - for _, hn := range hostnames { - key := strings.ToLower(strings.TrimSpace(hn)) - if key == "" { - continue - } - if cur, exists := hnMap[key]; !exists || resourceTypePriority(ref.ResourceType) > resourceTypePriority(cur.ResourceType) { - hnMap[key] = ref - } - } - } - } - - e.mu.Lock() - e.trapDict = dict - e.syslogRules = syslog - e.trapRules = trap - e.shields = shield - e.resourceByIP = ipMap - e.resourceByHN = hnMap - e.mu.Unlock() - return nil -} - -func StartRefresher() { - interval := config.Spec.Ingest.RuleRefreshSecs - if interval <= 0 { - interval = 30 - } - _ = Global.Refresh() - go func() { - t := time.NewTicker(time.Duration(interval) * time.Second) - defer t.Stop() - for range t.C { - _ = Global.Refresh() - } - }() -} - -func normOID(s string) string { - s = strings.TrimSpace(s) - return strings.TrimPrefix(s, ".") -} - -func (e *Engine) HandleSyslog(addr *net.UDPAddr, payload []byte) { - parsed := parseSyslogPayload(payload) - device := parsed.Hostname - if device == "" { - device = addr.IP.String() - } - detailObj := map[string]interface{}{ - "priority": parsed.Priority, - "hostname": parsed.Hostname, - "tag": parsed.Tag, - "message": parsed.Message, - } - detailBytes, _ := json.Marshal(detailObj) - summary := formatSyslogSummary(parsed) - sev := syslogPriorityToSeverity(parsed.Priority) - ref, method := e.resolveResource(addr.IP.String(), device) - - ev := models.LogEvent{ - SourceKind: "syslog", - RemoteAddr: addr.String(), - SourceIP: addr.IP.String(), - RawPayload: string(payload), - NormalizedSummary: summary, - NormalizedDetail: string(detailBytes), - DeviceName: device, - ResourceType: ref.ResourceType, - ResourceID: ref.ResourceID, - ResourceName: ref.ResourceName, - MatchMethod: method, - DispatchStatus: "not_applicable", - SeverityCode: sev, - } - - e.mu.RLock() - rules := e.syslogRules - e.mu.RUnlock() - - var matched *models.SyslogRule - for i := range rules { - if syslogRuleMatches(&rules[i], device, parsed.Message, parsed.RawLine) { - matched = &rules[i] - break - } - } - - if err := impl.DBService.Create(&ev).Error; err != nil { - return - } - - if matched == nil { - return - } - // 与 alert/doc/17-resource-correlation 约定一致(字段映射) - labels := map[string]string{ - "source_type": "log", - "source_subtype": "syslog", - "resource_type": "log_rule", - "resource_id": strconv.FormatUint(uint64(matched.ID), 10), - "rule_name": matched.Name, - "device": device, - "remote_addr": addr.String(), - "ip": addr.IP.String(), - "instance": firstNonEmpty(device, addr.String()), - "job": "logs-syslog", - } - rawObj := map[string]interface{}{ - "source": "syslog", - "received_at": time.Now().UTC().Format(time.RFC3339), - "source_ip": addr.IP.String(), - "rule_id": matched.ID, - "log_entry_id": ev.ID, - "raw_packet": string(payload), - "parsed": detailObj, - } - rawBytes, mErr := json.Marshal(rawObj) - if mErr != nil { - return - } - body := AlertReceiveBody{ - AlertName: matched.AlertName, - Summary: summary, - Description: summary, - SeverityCode: firstNonEmpty(matched.SeverityCode, sev), - Value: parsed.Message, - Labels: labels, - Agent: "logs-syslog", - PolicyID: matched.PolicyID, - RawData: rawBytes, - } - if err := enqueueAlert(ev.ID, body); err == nil { - _ = impl.DBService.Model(&ev).Update("dispatch_status", "pending").Error - } -} - -func syslogRuleMatches(rule *models.SyslogRule, device, message, rawLine string) bool { - if strings.TrimSpace(rule.DeviceNameContains) == "" && strings.TrimSpace(rule.KeywordRegex) == "" { - return false - } - deviceName := strings.ToLower(device) - contains := strings.ToLower(rule.DeviceNameContains) - if contains != "" && !strings.Contains(deviceName, contains) { - return false - } - if rule.KeywordRegex != "" { - re, err := regexp.Compile(rule.KeywordRegex) - if err != nil { - return false - } - if !re.MatchString(message) && !re.MatchString(rawLine) { - return false - } - } - return true -} - -func trapShielded(e *Engine, addr *net.UDPAddr, trapOID string, pkt *gosnmp.SnmpPacket) bool { - ip := addr.IP - fp := varbindFingerprint(pkt) - now := time.Now() - e.mu.RLock() - shields := e.shields - e.mu.RUnlock() - for i := range shields { - s := &shields[i] - if !s.Enabled { - continue - } - if cidr := strings.TrimSpace(s.SourceIPCIDR); cidr != "" && !ipMatchesCIDR(ip, cidr) { - continue - } - if p := strings.TrimSpace(s.OIDPrefix); p != "" && !strings.HasPrefix(normOID(trapOID), normOID(p)) { - continue - } - if h := strings.TrimSpace(s.InterfaceHint); h != "" && !strings.Contains(fp, h) { - continue - } - if !inTimeWindows(now, s.TimeWindowsJSON) { - continue - } - return true - } - return false -} - -func lookupTrapDict(e *Engine, trapOID string) *models.TrapDictionaryEntry { - t := normOID(trapOID) - e.mu.RLock() - dict := e.trapDict - e.mu.RUnlock() - for i := range dict { - if strings.HasPrefix(t, normOID(dict[i].OIDPrefix)) { - return &dict[i] - } - } - return nil -} - -func (e *Engine) HandleTrap(addr *net.UDPAddr, pkt *gosnmp.SnmpPacket) { - trapOID := extractTrapOID(pkt) - if trapShielded(e, addr, trapOID, pkt) { - return - } - - dict := lookupTrapDict(e, trapOID) - fp := varbindFingerprint(pkt) - vbJSON, _ := json.Marshal(trapVarbinds(pkt)) - - readable := buildTrapReadable(trapOID, dict, fp) - detailObj := map[string]interface{}{ - "trap_oid": trapOID, - "varbinds": trapVarbinds(pkt), - "dict_title": "", - "dict_description": "", - "recovery": "", - } - sev := "warning" - if dict != nil { - detailObj["dict_title"] = dict.Title - detailObj["dict_description"] = dict.Description - detailObj["recovery"] = dict.RecoveryMessage - if dict.SeverityCode != "" { - sev = dict.SeverityCode - } - } - detailBytes, _ := json.Marshal(detailObj) - ref, method := e.resolveResource(addr.IP.String(), addr.IP.String()) - - ev := models.LogEvent{ - SourceKind: "snmp_trap", - RemoteAddr: addr.String(), - SourceIP: addr.IP.String(), - RawPayload: fp, - NormalizedSummary: readable, - NormalizedDetail: string(detailBytes), - DeviceName: addr.IP.String(), - ResourceType: ref.ResourceType, - ResourceID: ref.ResourceID, - ResourceName: ref.ResourceName, - MatchMethod: method, - DispatchStatus: "not_applicable", - SeverityCode: sev, - TrapOID: trapOID, - } - if err := impl.DBService.Create(&ev).Error; err != nil { - return - } - - e.mu.RLock() - rules := e.trapRules - e.mu.RUnlock() - - var matched *models.TrapRule - 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(dict.Title, "SNMP Trap"), - SeverityCode: dict.SeverityCode, - PolicyID: 0, - } - } - if matched == nil { - return - } - - desc := readable - if dict != nil && dict.RecoveryMessage != "" { - desc = readable + "\n恢复建议: " + dict.RecoveryMessage - } - labels := map[string]string{ - "source_type": "log", - "source_subtype": "snmp_trap", - "trap_oid": trapOID, - "remote_addr": addr.String(), - "ip": addr.IP.String(), - "instance": addr.IP.String(), - "job": "logs-trap", - } - if matched.ID != 0 { - labels["resource_type"] = "trap_rule" - labels["resource_id"] = strconv.FormatUint(uint64(matched.ID), 10) - labels["rule_name"] = matched.Name - } else { - labels["resource_type"] = "trap_dictionary" - if trapOID != "" { - labels["resource_id"] = trapOID - } - } - resolved := map[string]interface{}{} - if dict != nil { - resolved["title"] = dict.Title - resolved["description"] = dict.Description - resolved["recovery"] = dict.RecoveryMessage - } - rawObj := map[string]interface{}{ - "source": "snmp_trap", - "received_at": time.Now().UTC().Format(time.RFC3339), - "source_ip": addr.IP.String(), - "log_entry_id": ev.ID, - "trap_oid": trapOID, - "varbinds": trapVarbinds(pkt), - "resolved": resolved, - "pdu_summary": fp, - } - if matched.ID != 0 { - rawObj["rule_id"] = matched.ID - } - rawBytes, mErr := json.Marshal(rawObj) - if mErr != nil { - return - } - body := AlertReceiveBody{ - AlertName: firstNonEmpty(matched.AlertName, "SNMP Trap"), - Summary: readable, - Description: desc, - SeverityCode: firstNonEmpty(matched.SeverityCode, sev), - Value: string(vbJSON), - Labels: labels, - Agent: "logs-trap", - PolicyID: matched.PolicyID, - RawData: rawBytes, - } - if err := enqueueAlert(ev.ID, body); err == nil { - _ = impl.DBService.Model(&ev).Update("dispatch_status", "pending").Error - } -} - -func extractTrapOID(pkt *gosnmp.SnmpPacket) string { - const snmpTrapOID = "1.3.6.1.6.3.1.1.4.1.0" - for _, v := range pkt.Variables { - if v.Name == snmpTrapOID || strings.HasSuffix(v.Name, ".1.3.6.1.6.3.1.1.4.1.0") { - return oidToString(v.Value) - } - } - for _, v := range pkt.Variables { - if strings.Contains(v.Name, "1.3.6.1.6.3.1.1.4.1") { - return oidToString(v.Value) - } - } - return "" -} - -func oidToString(val interface{}) string { - switch x := val.(type) { - case string: - return x - case []byte: - return string(x) - default: - return fmt.Sprintf("%v", x) - } -} - -func trapVarbinds(pkt *gosnmp.SnmpPacket) []map[string]string { - out := make([]map[string]string, 0, len(pkt.Variables)) - for _, v := range pkt.Variables { - out = append(out, map[string]string{ - "oid": v.Name, - "type": fmt.Sprintf("%v", v.Type), - "value": fmtVarbindValue(v), - }) - } - return out -} - -func buildTrapReadable(trapOID string, dict *models.TrapDictionaryEntry, varbindSummary string) string { - if dict != nil && dict.Title != "" { - return dict.Title + " (" + trapOID + ")" - } - if trapOID != "" { - return "Trap " + trapOID - } - return truncate(varbindSummary, 256) -} - -func trapRuleMatches(rule *models.TrapRule, trapOID, varbindFP string) bool { - hasOID := strings.TrimSpace(rule.OIDPrefix) != "" - hasRE := strings.TrimSpace(rule.VarbindMatchRegex) != "" - if !hasOID && !hasRE { - return false - } - if hasOID && !strings.HasPrefix(normOID(trapOID), normOID(rule.OIDPrefix)) { - return false - } - if rule.VarbindMatchRegex != "" { - re, err := regexp.Compile(rule.VarbindMatchRegex) - if err != nil { - return false - } - if !re.MatchString(varbindFP) { - return false - } - } - return true -} - -func firstNonEmpty(a, b string) string { - if strings.TrimSpace(a) != "" { - return a - } - return b -} - -func (e *Engine) resolveResource(sourceIP, hostname string) (resourceRef, string) { - e.mu.RLock() - ipMap := e.resourceByIP - hnMap := e.resourceByHN - e.mu.RUnlock() - - if ref, ok := ipMap[strings.TrimSpace(sourceIP)]; ok { - return ref, "ip" - } - if ref, ok := hnMap[strings.ToLower(strings.TrimSpace(hostname))]; ok { - return ref, "hostname" - } - return resourceRef{}, "none" -} +package ingest + +import ( + "encoding/json" + "fmt" + "net" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "git.apinb.com/ops/logs/internal/config" + "git.apinb.com/ops/logs/internal/impl" + "git.apinb.com/ops/logs/internal/models" + "github.com/gosnmp/gosnmp" +) + +type Engine struct { + mu sync.RWMutex + + trapDict []models.TrapDictionaryEntry + syslogRules []models.SyslogRule + trapRules []models.TrapRule + shields []models.TrapShield + resourceByIP map[string]resourceRef + resourceByHN map[string]resourceRef +} + +type resourceRef struct { + ResourceType string + ResourceID string + ResourceName string +} + +func resourceTypePriority(resourceType string) int { + switch strings.ToLower(strings.TrimSpace(resourceType)) { + case "server": + return 3 + case "collector": + return 2 + case "device": + return 1 + default: + return 0 + } +} + +var Global = &Engine{} + +func (e *Engine) Refresh() error { + var dict []models.TrapDictionaryEntry + var syslog []models.SyslogRule + var trap []models.TrapRule + var shield []models.TrapShield + var mappings []models.ResourceMapping + + if err := impl.DBService.Where("enabled = ?", true).Find(&dict).Error; err != nil { + return err + } + sort.Slice(dict, func(i, j int) bool { + return len(dict[i].OIDPrefix) > len(dict[j].OIDPrefix) + }) + + if err := impl.DBService.Where("enabled = ?", true).Find(&syslog).Error; err != nil { + return err + } + sort.Slice(syslog, func(i, j int) bool { return syslog[i].Priority > syslog[j].Priority }) + + if err := impl.DBService.Where("enabled = ?", true).Find(&trap).Error; err != nil { + return err + } + sort.Slice(trap, func(i, j int) bool { return trap[i].Priority > trap[j].Priority }) + + if err := impl.DBService.Where("enabled = ?", true).Find(&shield).Error; err != nil { + return err + } + if err := impl.DBService.Where("is_deleted = ?", false).Order("updated_at desc, id desc").Find(&mappings).Error; err != nil { + return err + } + + ipMap := make(map[string]resourceRef) + hnMap := make(map[string]resourceRef) + for _, m := range mappings { + ref := resourceRef{ + ResourceType: m.ResourceType, + ResourceID: m.ResourceID, + ResourceName: m.ResourceName, + } + var ips []string + if err := json.Unmarshal([]byte(m.IPsJSON), &ips); err == nil { + for _, ip := range ips { + key := strings.TrimSpace(ip) + if key == "" { + continue + } + if cur, exists := ipMap[key]; !exists || resourceTypePriority(ref.ResourceType) > resourceTypePriority(cur.ResourceType) { + ipMap[key] = ref + } + } + } + var hostnames []string + if err := json.Unmarshal([]byte(m.HostnamesJSON), &hostnames); err == nil { + for _, hn := range hostnames { + key := strings.ToLower(strings.TrimSpace(hn)) + if key == "" { + continue + } + if cur, exists := hnMap[key]; !exists || resourceTypePriority(ref.ResourceType) > resourceTypePriority(cur.ResourceType) { + hnMap[key] = ref + } + } + } + } + + e.mu.Lock() + e.trapDict = dict + e.syslogRules = syslog + e.trapRules = trap + e.shields = shield + e.resourceByIP = ipMap + e.resourceByHN = hnMap + e.mu.Unlock() + return nil +} + +func StartRefresher() { + interval := config.Spec.Ingest.RuleRefreshSecs + if interval <= 0 { + interval = 30 + } + _ = Global.Refresh() + go func() { + t := time.NewTicker(time.Duration(interval) * time.Second) + defer t.Stop() + for range t.C { + _ = Global.Refresh() + } + }() +} + +func normOID(s string) string { + s = strings.TrimSpace(s) + return strings.TrimPrefix(s, ".") +} + +func (e *Engine) HandleSyslog(addr *net.UDPAddr, payload []byte) { + parsed := parseSyslogPayload(payload) + device := parsed.Hostname + if device == "" { + device = addr.IP.String() + } + detailObj := map[string]interface{}{ + "priority": parsed.Priority, + "hostname": parsed.Hostname, + "tag": parsed.Tag, + "message": parsed.Message, + } + detailBytes, _ := json.Marshal(detailObj) + summary := formatSyslogSummary(parsed) + sev := syslogPriorityToSeverity(parsed.Priority) + ref, method := e.resolveResource(addr.IP.String(), device) + + ev := models.LogEvent{ + SourceKind: "syslog", + RemoteAddr: addr.String(), + SourceIP: addr.IP.String(), + RawPayload: string(payload), + NormalizedSummary: summary, + NormalizedDetail: string(detailBytes), + DeviceName: device, + ResourceType: ref.ResourceType, + ResourceID: ref.ResourceID, + ResourceName: ref.ResourceName, + MatchMethod: method, + DispatchStatus: "not_applicable", + SeverityCode: sev, + } + + e.mu.RLock() + rules := e.syslogRules + e.mu.RUnlock() + + var matched *models.SyslogRule + var matchDetails syslogRuleMatch + for i := range rules { + details := syslogRuleMatchDetails(&rules[i], device, parsed.Message, parsed.RawLine) + if details.Matched { + matched = &rules[i] + matchDetails = details + break + } + } + + if err := impl.DBService.Create(&ev).Error; err != nil { + return + } + + if matched == nil { + rawBytes, mErr := json.Marshal(string(payload)) + if mErr != nil { + return + } + body := AlertReceiveBody{ + AlertName: "未解析 Syslog", + Summary: summary, + Description: parsed.RawLine, + SeverityCode: sev, + Value: parsed.Message, + Labels: map[string]string{ + "source_type": "log", + "source_subtype": "syslog", + "device": device, + "remote_addr": addr.String(), + "ip": addr.IP.String(), + "instance": firstNonEmpty(device, addr.String()), + "job": "logs-syslog", + }, + Agent: "logs-syslog", + RawData: rawBytes, + } + if err := enqueueRawEvent(ev.ID, body, "unparsed"); err == nil { + _ = impl.DBService.Model(&ev).Update("dispatch_status", "pending").Error + } + return + } + // 与 alert/doc/17-resource-correlation 约定一致(字段映射) + labels := map[string]string{ + "source_type": "log", + "source_subtype": "syslog", + "resource_type": "log_rule", + "resource_id": strconv.FormatUint(uint64(matched.ID), 10), + "rule_name": matched.Name, + "device": device, + "remote_addr": addr.String(), + "ip": addr.IP.String(), + "instance": firstNonEmpty(device, addr.String()), + "job": "logs-syslog", + } + if matchDetails.ResourceUID != "" { + labels["resource_uid"] = matchDetails.ResourceUID + } + rawObj := map[string]interface{}{ + "source": "syslog", + "received_at": time.Now().UTC().Format(time.RFC3339), + "source_ip": addr.IP.String(), + "rule_id": matched.ID, + "log_entry_id": ev.ID, + "raw_packet": string(payload), + "parsed": detailObj, + "match": matchDetails.Captures, + } + rawBytes, mErr := json.Marshal(rawObj) + if mErr != nil { + return + } + body := AlertReceiveBody{ + AlertName: matched.AlertName, + Summary: summary, + Description: summary, + SeverityCode: firstNonEmpty(matchDetails.SeverityCode, firstNonEmpty(matched.SeverityCode, sev)), + Value: parsed.Message, + Labels: labels, + Agent: "logs-syslog", + PolicyID: matched.PolicyID, + RawData: rawBytes, + } + if err := enqueueAlert(ev.ID, body); err == nil { + _ = impl.DBService.Model(&ev).Update("dispatch_status", "pending").Error + } +} + +type syslogRuleMatch struct { + Matched bool + ResourceUID string + SeverityCode string + Captures map[string]string +} + +func syslogRuleMatches(rule *models.SyslogRule, device, message, rawLine string) bool { + return syslogRuleMatchDetails(rule, device, message, rawLine).Matched +} + +func syslogRuleMatchDetails(rule *models.SyslogRule, device, message, rawLine string) syslogRuleMatch { + result := syslogRuleMatch{Captures: map[string]string{}} + deviceContains := strings.TrimSpace(rule.DeviceNameContains) + sourceMatch := strings.TrimSpace(rule.SourceMatch) + keywordRegex := strings.TrimSpace(rule.KeywordRegex) + messageRegex := strings.TrimSpace(rule.MessageRegex) + if deviceContains == "" && sourceMatch == "" && keywordRegex == "" && messageRegex == "" { + return result + } + deviceName := strings.ToLower(device) + contains := strings.ToLower(deviceContains) + if contains != "" && !strings.Contains(deviceName, contains) { + return result + } + if sourceMatch != "" { + source := strings.ToLower(sourceMatch) + rawLower := strings.ToLower(rawLine) + msgLower := strings.ToLower(message) + if !strings.Contains(deviceName, source) && !strings.Contains(rawLower, source) && !strings.Contains(msgLower, source) { + return result + } + } + for _, pattern := range []string{keywordRegex, messageRegex} { + if pattern == "" { + continue + } + re, err := regexp.Compile(pattern) + if err != nil { + return result + } + matches := re.FindStringSubmatch(message) + if matches == nil { + matches = re.FindStringSubmatch(rawLine) + } + if matches == nil { + return result + } + mergeNamedCaptures(result.Captures, re, matches) + } + result.Matched = true + if uid := extractWithNamedRegex(rule.ResourceUIDExtractRegex, "resource_uid", message, rawLine); uid != "" { + result.ResourceUID = normalizeExtractedResourceUID(uid) + } else if uid := result.Captures["resource_uid"]; uid != "" { + result.ResourceUID = normalizeExtractedResourceUID(uid) + } + result.SeverityCode = mappedSeverity(rule.SeverityMappingJSON, message, rawLine) + return result +} + +func mergeNamedCaptures(dst map[string]string, re *regexp.Regexp, matches []string) { + names := re.SubexpNames() + for i, name := range names { + if i == 0 || name == "" || i >= len(matches) { + continue + } + dst[name] = matches[i] + } +} + +func extractWithNamedRegex(pattern, groupName, message, rawLine string) string { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return "" + } + re, err := regexp.Compile(pattern) + if err != nil { + return "" + } + for _, text := range []string{message, rawLine} { + matches := re.FindStringSubmatch(text) + if matches == nil { + continue + } + names := re.SubexpNames() + for i, name := range names { + if i > 0 && name == groupName && i < len(matches) { + return strings.TrimSpace(matches[i]) + } + } + for i := 1; i < len(matches); i++ { + if strings.TrimSpace(matches[i]) != "" { + return strings.TrimSpace(matches[i]) + } + } + } + return "" +} + +func normalizeExtractedResourceUID(uid string) string { + uid = strings.TrimSpace(uid) + if uid == "" || strings.Contains(uid, ":") { + return uid + } + return "network:" + uid +} + +func mappedSeverity(mappingJSON, message, rawLine string) string { + mappingJSON = strings.TrimSpace(mappingJSON) + if mappingJSON == "" { + return "" + } + var mapping map[string]string + if err := json.Unmarshal([]byte(mappingJSON), &mapping); err != nil { + return "" + } + for pattern, severity := range mapping { + re, err := regexp.Compile(pattern) + if err != nil { + continue + } + if re.MatchString(message) || re.MatchString(rawLine) { + return severity + } + } + return "" +} + +func trapShielded(e *Engine, addr *net.UDPAddr, trapOID string, pkt *gosnmp.SnmpPacket) bool { + ip := addr.IP + fp := varbindFingerprint(pkt) + now := time.Now() + e.mu.RLock() + shields := e.shields + e.mu.RUnlock() + for i := range shields { + s := &shields[i] + if !s.Enabled { + continue + } + if cidr := strings.TrimSpace(s.SourceIPCIDR); cidr != "" && !ipMatchesCIDR(ip, cidr) { + continue + } + if p := strings.TrimSpace(s.OIDPrefix); p != "" && !strings.HasPrefix(normOID(trapOID), normOID(p)) { + continue + } + if h := strings.TrimSpace(s.InterfaceHint); h != "" && !strings.Contains(fp, h) { + continue + } + if !inTimeWindows(now, s.TimeWindowsJSON) { + continue + } + return true + } + return false +} + +func lookupTrapDict(e *Engine, trapOID string) *models.TrapDictionaryEntry { + t := normOID(trapOID) + e.mu.RLock() + dict := e.trapDict + e.mu.RUnlock() + for i := range dict { + if oid := normOID(dict[i].OID); oid != "" && t == oid { + return &dict[i] + } + if prefix := normOID(dict[i].OIDPrefix); prefix != "" && strings.HasPrefix(t, prefix) { + return &dict[i] + } + } + return nil +} + +func (e *Engine) HandleTrap(addr *net.UDPAddr, pkt *gosnmp.SnmpPacket) { + trapOID := extractTrapOID(pkt) + if trapShielded(e, addr, trapOID, pkt) { + return + } + + dict := lookupTrapDict(e, trapOID) + fp := varbindFingerprint(pkt) + vbJSON, _ := json.Marshal(trapVarbinds(pkt)) + + readable := buildTrapReadable(trapOID, dict, fp) + detailObj := map[string]interface{}{ + "trap_oid": trapOID, + "varbinds": trapVarbinds(pkt), + "dict_title": "", + "dict_description": "", + "recovery": "", + } + sev := "warning" + if dict != nil { + detailObj["dict_title"] = firstNonEmpty(dict.Name, dict.Title) + detailObj["dict_description"] = dict.Description + detailObj["recovery"] = dict.RecoveryMessage + if dict.SeverityCode != "" { + sev = dict.SeverityCode + } + } + detailBytes, _ := json.Marshal(detailObj) + ref, method := e.resolveResource(addr.IP.String(), addr.IP.String()) + + ev := models.LogEvent{ + SourceKind: "snmp_trap", + RemoteAddr: addr.String(), + SourceIP: addr.IP.String(), + RawPayload: fp, + NormalizedSummary: readable, + NormalizedDetail: string(detailBytes), + DeviceName: addr.IP.String(), + ResourceType: ref.ResourceType, + ResourceID: ref.ResourceID, + ResourceName: ref.ResourceName, + MatchMethod: method, + DispatchStatus: "not_applicable", + SeverityCode: sev, + TrapOID: trapOID, + } + if err := impl.DBService.Create(&ev).Error; err != nil { + return + } + + e.mu.RLock() + rules := e.trapRules + e.mu.RUnlock() + + var matched *models.TrapRule + 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 { + rawBytes, mErr := json.Marshal(fp) + if mErr != nil { + return + } + body := AlertReceiveBody{ + AlertName: "未解析 SNMP Trap", + Summary: readable, + Description: fp, + SeverityCode: sev, + Value: string(vbJSON), + Labels: map[string]string{ + "source_type": "log", + "source_subtype": "snmp_trap", + "trap_oid": trapOID, + "remote_addr": addr.String(), + "ip": addr.IP.String(), + "instance": addr.IP.String(), + "job": "logs-trap", + }, + Agent: "logs-trap", + RawData: rawBytes, + } + if err := enqueueRawEvent(ev.ID, body, "unparsed"); err == nil { + _ = impl.DBService.Model(&ev).Update("dispatch_status", "pending").Error + } + return + } + + desc := readable + if dict != nil && dict.RecoveryMessage != "" { + desc = readable + "\n恢复建议: " + dict.RecoveryMessage + } + labels := map[string]string{ + "source_type": "log", + "source_subtype": "snmp_trap", + "trap_oid": trapOID, + "remote_addr": addr.String(), + "ip": addr.IP.String(), + "instance": addr.IP.String(), + "job": "logs-trap", + } + if matched.ID != 0 { + labels["resource_type"] = "trap_rule" + labels["resource_id"] = strconv.FormatUint(uint64(matched.ID), 10) + labels["rule_name"] = matched.Name + } else { + labels["resource_type"] = "trap_dictionary" + if trapOID != "" { + labels["resource_id"] = trapOID + } + } + resolved := map[string]interface{}{} + if dict != nil { + resolved["vendor"] = dict.Vendor + resolved["oid"] = firstNonEmpty(dict.OID, dict.OIDPrefix) + resolved["title"] = firstNonEmpty(dict.Name, dict.Title) + resolved["description"] = dict.Description + resolved["recovery"] = dict.RecoveryMessage + resolved["severity_mapping"] = dict.SeverityMappingJSON + resolved["parse_expression"] = dict.ParseExpression + } + rawObj := map[string]interface{}{ + "source": "snmp_trap", + "received_at": time.Now().UTC().Format(time.RFC3339), + "source_ip": addr.IP.String(), + "log_entry_id": ev.ID, + "trap_oid": trapOID, + "varbinds": trapVarbinds(pkt), + "resolved": resolved, + "pdu_summary": fp, + } + if matched.ID != 0 { + rawObj["rule_id"] = matched.ID + } + rawBytes, mErr := json.Marshal(rawObj) + if mErr != nil { + return + } + body := AlertReceiveBody{ + AlertName: firstNonEmpty(matched.AlertName, "SNMP Trap"), + Summary: readable, + Description: desc, + SeverityCode: firstNonEmpty(matched.SeverityCode, sev), + Value: string(vbJSON), + Labels: labels, + Agent: "logs-trap", + PolicyID: matched.PolicyID, + RawData: rawBytes, + } + if err := enqueueAlert(ev.ID, body); err == nil { + _ = impl.DBService.Model(&ev).Update("dispatch_status", "pending").Error + } +} + +func extractTrapOID(pkt *gosnmp.SnmpPacket) string { + const snmpTrapOID = "1.3.6.1.6.3.1.1.4.1.0" + for _, v := range pkt.Variables { + if v.Name == snmpTrapOID || strings.HasSuffix(v.Name, ".1.3.6.1.6.3.1.1.4.1.0") { + return oidToString(v.Value) + } + } + for _, v := range pkt.Variables { + if strings.Contains(v.Name, "1.3.6.1.6.3.1.1.4.1") { + return oidToString(v.Value) + } + } + return "" +} + +func oidToString(val interface{}) string { + switch x := val.(type) { + case string: + return x + case []byte: + return string(x) + default: + return fmt.Sprintf("%v", x) + } +} + +func trapVarbinds(pkt *gosnmp.SnmpPacket) []map[string]string { + out := make([]map[string]string, 0, len(pkt.Variables)) + for _, v := range pkt.Variables { + out = append(out, map[string]string{ + "oid": v.Name, + "type": fmt.Sprintf("%v", v.Type), + "value": fmtVarbindValue(v), + }) + } + return out +} + +func buildTrapReadable(trapOID string, dict *models.TrapDictionaryEntry, varbindSummary string) string { + if dict != nil && firstNonEmpty(dict.Name, dict.Title) != "" { + return firstNonEmpty(dict.Name, dict.Title) + " (" + trapOID + ")" + } + if trapOID != "" { + return "Trap " + trapOID + } + return truncate(varbindSummary, 256) +} + +func trapRuleMatches(rule *models.TrapRule, trapOID, varbindFP string) bool { + hasOID := strings.TrimSpace(rule.OIDPrefix) != "" + hasRE := strings.TrimSpace(rule.VarbindMatchRegex) != "" + if !hasOID && !hasRE { + return false + } + if hasOID && !strings.HasPrefix(normOID(trapOID), normOID(rule.OIDPrefix)) { + return false + } + if rule.VarbindMatchRegex != "" { + re, err := regexp.Compile(rule.VarbindMatchRegex) + if err != nil { + return false + } + if !re.MatchString(varbindFP) { + return false + } + } + return true +} + +func firstNonEmpty(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + +func (e *Engine) resolveResource(sourceIP, hostname string) (resourceRef, string) { + e.mu.RLock() + ipMap := e.resourceByIP + hnMap := e.resourceByHN + e.mu.RUnlock() + + if ref, ok := ipMap[strings.TrimSpace(sourceIP)]; ok { + return ref, "ip" + } + if ref, ok := hnMap[strings.ToLower(strings.TrimSpace(hostname))]; ok { + return ref, "hostname" + } + return resourceRef{}, "none" +} diff --git a/internal/ingest/replay.go b/internal/ingest/replay.go new file mode 100644 index 0000000..37d7078 --- /dev/null +++ b/internal/ingest/replay.go @@ -0,0 +1,131 @@ +package ingest + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "git.apinb.com/ops/logs/internal/impl" + "git.apinb.com/ops/logs/internal/models" +) + +func BuildReplayRawEventPayload(ev models.LogEvent) (RawEventIngestBody, error) { + sourceType := replaySourceType(ev.SourceKind) + if sourceType == "" { + return RawEventIngestBody{}, fmt.Errorf("unsupported source kind %q", ev.SourceKind) + } + rawObj := map[string]interface{}{ + "source": sourceType, + "replayed_at": time.Now().UTC().Format(time.RFC3339), + "log_entry_id": ev.ID, + "source_ip": ev.SourceIP, + "remote_addr": ev.RemoteAddr, + "raw_packet": ev.RawPayload, + } + if ev.TrapOID != "" { + rawObj["trap_oid"] = ev.TrapOID + } + if ev.NormalizedDetail != "" { + var detail interface{} + if err := json.Unmarshal([]byte(ev.NormalizedDetail), &detail); err == nil { + rawObj["parsed"] = detail + } + } + rawBytes, err := json.Marshal(rawObj) + if err != nil { + return RawEventIngestBody{}, err + } + labels := map[string]string{ + "source_type": "log", + "source_subtype": replaySubtype(ev.SourceKind), + "replay_of_log_event_id": strconv.FormatUint(uint64(ev.ID), 10), + "ip": ev.SourceIP, + "remote_addr": ev.RemoteAddr, + "device": ev.DeviceName, + "job": "logs-replay", + } + if uid := replayResourceUID(ev); uid != "" { + labels["resource_uid"] = uid + } + return RawEventIngestBody{ + SourceType: sourceType, + ResourceUID: replayResourceUID(ev), + EventTime: time.Now().UTC(), + Severity: firstNonEmpty(ev.SeverityCode, "warning"), + Title: replayTitle(ev), + Message: firstNonEmpty(ev.NormalizedSummary, ev.RawPayload), + Labels: labels, + Annotations: map[string]string{ + "replay": "true", + "dispatch_status": ev.DispatchStatus, + }, + ParseStatus: "replayed", + RawPayload: rawBytes, + }, nil +} + +func EnqueueReplayLogEvent(ev models.LogEvent) (uint, error) { + body, err := BuildReplayRawEventPayload(ev) + if err != nil { + return 0, err + } + payload, err := json.Marshal(body) + if err != nil { + return 0, err + } + row := models.AlertOutbox{ + LogEventID: ev.ID, + PayloadJSON: string(payload), + Status: outboxStatusPending, + RetryCount: 0, + NextRetryAt: time.Now(), + } + if err := enqueueOutboxRow(&row); err != nil { + return 0, err + } + return row.ID, nil +} + +func enqueueOutboxRow(row *models.AlertOutbox) error { + return impl.DBService.Create(row).Error +} + +func replaySourceType(kind string) string { + switch strings.TrimSpace(kind) { + case "syslog": + return "syslog" + case "snmp_trap", "trap": + return "trap" + default: + return "" + } +} + +func replaySubtype(kind string) string { + if kind == "snmp_trap" { + return "snmp_trap" + } + return replaySourceType(kind) +} + +func replayResourceUID(ev models.LogEvent) string { + if strings.Contains(ev.ResourceID, ":") { + return ev.ResourceID + } + if ev.ResourceType != "" && ev.ResourceID != "" { + return ev.ResourceType + ":" + ev.ResourceID + } + return "" +} + +func replayTitle(ev models.LogEvent) string { + if ev.SourceKind == "snmp_trap" && ev.TrapOID != "" { + return "重放 SNMP Trap " + ev.TrapOID + } + if ev.SourceKind == "syslog" { + return "重放 Syslog" + } + return "重放日志事件" +} diff --git a/internal/ingest/replay_test.go b/internal/ingest/replay_test.go new file mode 100644 index 0000000..a19a758 --- /dev/null +++ b/internal/ingest/replay_test.go @@ -0,0 +1,61 @@ +package ingest + +import ( + "encoding/json" + "testing" + + "git.apinb.com/ops/logs/internal/models" +) + +func TestSyslogRuleMatchDetailsExtractsResourceUID(t *testing.T) { + rule := models.SyslogRule{ + Name: "H3C link down", + Enabled: true, + SourceMatch: "h3c-core", + MessageRegex: `Interface (?PGigabitEthernet[0-9/]+) is down`, + ResourceUIDExtractRegex: `Interface (?PGigabitEthernet[0-9/]+) is down`, + } + + match := syslogRuleMatchDetails(&rule, "h3c-core-01", "Interface GigabitEthernet1/0/1 is down", "") + if !match.Matched { + t.Fatal("expected rule to match") + } + if match.ResourceUID != "network:GigabitEthernet1/0/1" { + t.Fatalf("unexpected resource uid: %q", match.ResourceUID) + } +} + +func TestBuildReplayRawEventPayloadMarksReplayed(t *testing.T) { + ev := models.LogEvent{ + ID: 12, + SourceKind: "syslog", + SourceIP: "10.1.2.3", + RemoteAddr: "10.1.2.3:514", + DeviceName: "h3c-core-01", + RawPayload: "<189>Jun 24 10:00:01 h3c-core-01 IFNET/4/LINK_DOWN: Interface GigabitEthernet1/0/1 is down", + NormalizedSummary: "h3c-core-01: Interface GigabitEthernet1/0/1 is down", + SeverityCode: "warning", + DispatchStatus: "pending", + } + + body, err := BuildReplayRawEventPayload(ev) + if err != nil { + t.Fatalf("BuildReplayRawEventPayload returned error: %v", err) + } + if body.SourceType != "syslog" { + t.Fatalf("unexpected source type: %q", body.SourceType) + } + if body.ParseStatus != "replayed" { + t.Fatalf("unexpected parse status: %q", body.ParseStatus) + } + if body.Labels["replay_of_log_event_id"] != "12" { + t.Fatalf("missing replay label: %#v", body.Labels) + } + var raw map[string]any + if err := json.Unmarshal(body.RawPayload, &raw); err != nil { + t.Fatalf("raw payload should be json: %v", err) + } + if raw["raw_packet"] == "" { + t.Fatalf("raw packet missing: %#v", raw) + } +} diff --git a/internal/logic/audit/audit.go b/internal/logic/audit/audit.go new file mode 100644 index 0000000..fcfd916 --- /dev/null +++ b/internal/logic/audit/audit.go @@ -0,0 +1,219 @@ +package audit + +import ( + "errors" + "strings" + "time" + + "git.apinb.com/ops/logs/internal/impl" + "git.apinb.com/ops/logs/internal/models" +) + +const ( + RiskNormal = "normal" + RiskDangerous = "dangerous" + + ApprovalPending = "pending" + ApprovalApproved = "approved" + ApprovalRejected = "rejected" +) + +type Record struct { + TraceID string `json:"trace_id,omitempty"` + SourceService string `json:"source_service,omitempty"` + ActorID string `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + Action string `json:"action,omitempty"` + ObjectType string `json:"object_type,omitempty"` + ObjectID string `json:"object_id,omitempty"` + OperationRisk string `json:"operation_risk,omitempty"` + ApprovalID string `json:"approval_id,omitempty"` + RequestMethod string `json:"request_method,omitempty"` + RequestPath string `json:"request_path,omitempty"` + ClientIP string `json:"client_ip,omitempty"` + BeforeJSON string `json:"before_json,omitempty"` + AfterJSON string `json:"after_json,omitempty"` + Result string `json:"result,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type ApprovalRequest struct { + RequestID string `json:"request_id,omitempty"` + SourceService string `json:"source_service,omitempty"` + Action string `json:"action,omitempty"` + ObjectType string `json:"object_type,omitempty"` + ObjectID string `json:"object_id,omitempty"` + RequesterID string `json:"requester_id,omitempty"` + RequesterName string `json:"requester_name,omitempty"` + Reason string `json:"reason,omitempty"` + BeforeJSON string `json:"before_json,omitempty"` + AfterJSON string `json:"after_json,omitempty"` + Status string `json:"status,omitempty"` + ReviewerID string `json:"reviewer_id,omitempty"` + ReviewerName string `json:"reviewer_name,omitempty"` + ReviewComment string `json:"review_comment,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` +} + +func NormalizeRecord(record Record) Record { + record.TraceID = strings.TrimSpace(record.TraceID) + record.SourceService = strings.TrimSpace(record.SourceService) + record.ActorID = strings.TrimSpace(record.ActorID) + record.ActorName = strings.TrimSpace(record.ActorName) + record.Action = strings.TrimSpace(record.Action) + record.ObjectType = strings.TrimSpace(record.ObjectType) + record.ObjectID = strings.TrimSpace(record.ObjectID) + record.OperationRisk = strings.TrimSpace(strings.ToLower(record.OperationRisk)) + record.ApprovalID = strings.TrimSpace(record.ApprovalID) + record.RequestMethod = strings.TrimSpace(strings.ToUpper(record.RequestMethod)) + record.RequestPath = strings.TrimSpace(record.RequestPath) + record.ClientIP = strings.TrimSpace(record.ClientIP) + record.Result = strings.TrimSpace(record.Result) + if record.Result == "" { + record.Result = "success" + } + if record.OperationRisk == "" { + if IsDangerousOperation(record.Action, record.ObjectType) { + record.OperationRisk = RiskDangerous + } else { + record.OperationRisk = RiskNormal + } + } + return record +} + +func ValidateRecord(record Record) error { + record = NormalizeRecord(record) + if record.SourceService == "" { + return errors.New("source_service is required") + } + if record.ActorID == "" { + return errors.New("actor_id is required") + } + if record.Action == "" { + return errors.New("action is required") + } + if record.ObjectType == "" { + return errors.New("object_type is required") + } + if record.ObjectID == "" { + return errors.New("object_id is required") + } + if record.OperationRisk != RiskNormal && record.OperationRisk != RiskDangerous { + return errors.New("operation_risk must be normal or dangerous") + } + if record.OperationRisk == RiskDangerous && record.ApprovalID == "" { + return errors.New("approval_id is required for dangerous operation") + } + return nil +} + +func IsDangerousOperation(action, objectType string) bool { + key := strings.ToLower(strings.TrimSpace(action) + " " + strings.TrimSpace(objectType)) + dangerWords := []string{ + "notification_policy", + "notification policy", + "silence_policy", + "suppression", + "escalation_policy", + "automation_script", + "script.execute", + "script.rollback", + } + for _, word := range dangerWords { + if strings.Contains(key, word) { + return true + } + } + return false +} + +func SaveRecord(record Record) (models.AuditLog, error) { + record = NormalizeRecord(record) + if err := ValidateRecord(record); err != nil { + return models.AuditLog{}, err + } + row := models.AuditLog{ + TraceID: record.TraceID, + SourceService: record.SourceService, + ActorID: record.ActorID, + ActorName: record.ActorName, + Action: record.Action, + ObjectType: record.ObjectType, + ObjectID: record.ObjectID, + OperationRisk: record.OperationRisk, + ApprovalID: record.ApprovalID, + RequestMethod: record.RequestMethod, + RequestPath: record.RequestPath, + ClientIP: record.ClientIP, + BeforeJSON: record.BeforeJSON, + AfterJSON: record.AfterJSON, + Result: record.Result, + ErrorMessage: record.ErrorMessage, + } + if err := impl.DBService.Create(&row).Error; err != nil { + return models.AuditLog{}, err + } + return row, nil +} + +func NormalizeApproval(req ApprovalRequest) ApprovalRequest { + req.RequestID = strings.TrimSpace(req.RequestID) + req.SourceService = strings.TrimSpace(req.SourceService) + req.Action = strings.TrimSpace(req.Action) + req.ObjectType = strings.TrimSpace(req.ObjectType) + req.ObjectID = strings.TrimSpace(req.ObjectID) + req.RequesterID = strings.TrimSpace(req.RequesterID) + req.RequesterName = strings.TrimSpace(req.RequesterName) + req.Status = strings.TrimSpace(strings.ToLower(req.Status)) + req.ReviewerID = strings.TrimSpace(req.ReviewerID) + req.ReviewerName = strings.TrimSpace(req.ReviewerName) + if req.Status == "" { + req.Status = ApprovalPending + } + return req +} + +func ValidateApprovalRequest(req ApprovalRequest) error { + req = NormalizeApproval(req) + if req.SourceService == "" { + return errors.New("source_service is required") + } + if req.Action == "" { + return errors.New("action is required") + } + if req.ObjectType == "" { + return errors.New("object_type is required") + } + if req.ObjectID == "" { + return errors.New("object_id is required") + } + if req.RequesterID == "" { + return errors.New("requester_id is required") + } + if !IsDangerousOperation(req.Action, req.ObjectType) { + return errors.New("operation is not classified as dangerous") + } + return nil +} + +func Transition(req ApprovalRequest, nextStatus, reviewerID, comment string) (ApprovalRequest, error) { + req = NormalizeApproval(req) + nextStatus = strings.TrimSpace(strings.ToLower(nextStatus)) + reviewerID = strings.TrimSpace(reviewerID) + if req.Status != ApprovalPending { + return ApprovalRequest{}, errors.New("only pending approval can be reviewed") + } + if nextStatus != ApprovalApproved && nextStatus != ApprovalRejected { + return ApprovalRequest{}, errors.New("next status must be approved or rejected") + } + if reviewerID == "" { + return ApprovalRequest{}, errors.New("reviewer_id is required") + } + now := time.Now() + req.Status = nextStatus + req.ReviewerID = reviewerID + req.ReviewComment = strings.TrimSpace(comment) + req.ReviewedAt = &now + return req, nil +} diff --git a/internal/logic/audit/audit_test.go b/internal/logic/audit/audit_test.go new file mode 100644 index 0000000..c1faaff --- /dev/null +++ b/internal/logic/audit/audit_test.go @@ -0,0 +1,61 @@ +package audit + +import ( + "testing" +) + +func TestValidateRecordRequiresDangerousOperationsToCarryReviewID(t *testing.T) { + record := Record{ + SourceService: "alert", + ActorID: "u-1", + Action: "policy.update", + ObjectType: "notification_policy", + ObjectID: "np-1", + OperationRisk: RiskDangerous, + } + + if err := ValidateRecord(record); err == nil { + t.Fatal("expected dangerous operation without approval id to fail") + } + + record.ApprovalID = "apr-1" + if err := ValidateRecord(record); err != nil { + t.Fatalf("expected valid dangerous audit record, got %v", err) + } +} + +func TestNormalizeRecordClassifiesDangerousActions(t *testing.T) { + record := NormalizeRecord(Record{ + SourceService: " alert ", + Action: "notification_policy.update", + ObjectType: " notification_policy ", + ObjectID: " np-1 ", + ActorID: " u-1 ", + }) + + if record.SourceService != "alert" || record.ObjectType != "notification_policy" || record.ObjectID != "np-1" { + t.Fatalf("record was not normalized: %#v", record) + } + if record.OperationRisk != RiskDangerous { + t.Fatalf("notification policy changes must be dangerous, got %q", record.OperationRisk) + } +} + +func TestApprovalTransitionAllowsApproveOnlyFromPending(t *testing.T) { + req := ApprovalRequest{Status: ApprovalPending} + + approved, err := Transition(req, ApprovalApproved, "reviewer-1", "ok") + if err != nil { + t.Fatalf("expected pending approval to approve: %v", err) + } + if approved.Status != ApprovalApproved { + t.Fatalf("unexpected status: %s", approved.Status) + } + if approved.ReviewerID != "reviewer-1" || approved.ReviewComment != "ok" { + t.Fatalf("review metadata not stored: %#v", approved) + } + + if _, err := Transition(approved, ApprovalRejected, "reviewer-2", "late"); err == nil { + t.Fatal("expected approved request to reject further transition") + } +} diff --git a/internal/logic/audit/controller.go b/internal/logic/audit/controller.go new file mode 100644 index 0000000..2b9b423 --- /dev/null +++ b/internal/logic/audit/controller.go @@ -0,0 +1,207 @@ +package audit + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/ops/logs/internal/impl" + "git.apinb.com/ops/logs/internal/models" + "github.com/gin-gonic/gin" +) + +func ListAuditLogs(ctx *gin.Context) { + page, size := pageAndSize(ctx.DefaultQuery("page", "1"), ctx.DefaultQuery("page_size", "50")) + q := impl.DBService.Model(&models.AuditLog{}) + if v := strings.TrimSpace(ctx.Query("source_service")); v != "" { + q = q.Where("source_service = ?", v) + } + if v := strings.TrimSpace(ctx.Query("actor_id")); v != "" { + q = q.Where("actor_id = ?", v) + } + if v := strings.TrimSpace(ctx.Query("action")); v != "" { + q = q.Where("action = ?", v) + } + if v := strings.TrimSpace(ctx.Query("object_type")); v != "" { + q = q.Where("object_type = ?", v) + } + if v := strings.TrimSpace(ctx.Query("object_id")); v != "" { + q = q.Where("object_id = ?", v) + } + if v := strings.TrimSpace(ctx.Query("operation_risk")); v != "" { + q = q.Where("operation_risk = ?", v) + } + if v := strings.TrimSpace(ctx.Query("result")); v != "" { + q = q.Where("result = ?", v) + } + var total int64 + _ = q.Count(&total).Error + var rows []models.AuditLog + if err := q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "page": page, "page_size": size, "items": rows}) +} + +func CreateAuditLog(ctx *gin.Context) { + var req Record + if err := ctx.ShouldBindJSON(&req); err != nil { + infra.Response.Error(ctx, err) + return + } + req.ClientIP = firstNonEmpty(req.ClientIP, ctx.ClientIP()) + req.RequestMethod = firstNonEmpty(req.RequestMethod, ctx.Request.Method) + req.RequestPath = firstNonEmpty(req.RequestPath, ctx.FullPath()) + row, err := SaveRecord(req) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, row) +} + +func ListApprovals(ctx *gin.Context) { + page, size := pageAndSize(ctx.DefaultQuery("page", "1"), ctx.DefaultQuery("page_size", "50")) + q := impl.DBService.Model(&models.DangerousOperationApproval{}) + if v := strings.TrimSpace(ctx.Query("source_service")); v != "" { + q = q.Where("source_service = ?", v) + } + if v := strings.TrimSpace(ctx.Query("status")); v != "" { + q = q.Where("status = ?", v) + } + if v := strings.TrimSpace(ctx.Query("requester_id")); v != "" { + q = q.Where("requester_id = ?", v) + } + if v := strings.TrimSpace(ctx.Query("object_type")); v != "" { + q = q.Where("object_type = ?", v) + } + var total int64 + _ = q.Count(&total).Error + var rows []models.DangerousOperationApproval + if err := q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "page": page, "page_size": size, "items": rows}) +} + +func CreateApproval(ctx *gin.Context) { + var req ApprovalRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + infra.Response.Error(ctx, err) + return + } + req = NormalizeApproval(req) + if req.RequestID == "" { + req.RequestID = fmt.Sprintf("apr-%d", time.Now().UnixNano()) + } + if err := ValidateApprovalRequest(req); err != nil { + infra.Response.Error(ctx, err) + return + } + row := models.DangerousOperationApproval{ + RequestID: req.RequestID, + SourceService: req.SourceService, + Action: req.Action, + ObjectType: req.ObjectType, + ObjectID: req.ObjectID, + RequesterID: req.RequesterID, + RequesterName: req.RequesterName, + Reason: req.Reason, + BeforeJSON: req.BeforeJSON, + AfterJSON: req.AfterJSON, + Status: ApprovalPending, + } + if err := impl.DBService.Create(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, row) +} + +func ApproveApproval(ctx *gin.Context) { + reviewApproval(ctx, ApprovalApproved) +} + +func RejectApproval(ctx *gin.Context) { + reviewApproval(ctx, ApprovalRejected) +} + +func reviewApproval(ctx *gin.Context, next string) { + id, err := parseUintParam(ctx, "id") + if err != nil { + infra.Response.Error(ctx, err) + return + } + var body struct { + ReviewerID string `json:"reviewer_id"` + ReviewerName string `json:"reviewer_name"` + ReviewComment string `json:"review_comment"` + } + if err := ctx.ShouldBindJSON(&body); err != nil { + infra.Response.Error(ctx, err) + return + } + var row models.DangerousOperationApproval + if err := impl.DBService.First(&row, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + req := ApprovalRequest{ + RequestID: row.RequestID, + SourceService: row.SourceService, + Action: row.Action, + ObjectType: row.ObjectType, + ObjectID: row.ObjectID, + RequesterID: row.RequesterID, + Status: row.Status, + } + nextReq, err := Transition(req, next, body.ReviewerID, body.ReviewComment) + if err != nil { + infra.Response.Error(ctx, err) + return + } + row.Status = nextReq.Status + row.ReviewerID = nextReq.ReviewerID + row.ReviewerName = strings.TrimSpace(body.ReviewerName) + row.ReviewComment = nextReq.ReviewComment + row.ReviewedAt = nextReq.ReviewedAt + if err := impl.DBService.Save(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, row) +} + +func parseUintParam(ctx *gin.Context, name string) (uint, error) { + v, err := strconv.ParseUint(ctx.Param(name), 10, 32) + if err != nil || v == 0 { + return 0, errors.New("invalid id") + } + return uint(v), nil +} + +func pageAndSize(pageText, sizeText string) (int, int) { + page, _ := strconv.Atoi(pageText) + size, _ := strconv.Atoi(sizeText) + if page < 1 { + page = 1 + } + if size < 1 || size > 500 { + size = 50 + } + return page, size +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/logic/audit/middleware.go b/internal/logic/audit/middleware.go new file mode 100644 index 0000000..157f203 --- /dev/null +++ b/internal/logic/audit/middleware.go @@ -0,0 +1,66 @@ +package audit + +import ( + "bytes" + "io" + "strings" + + "github.com/gin-gonic/gin" +) + +type ActorResolver func(*gin.Context) (id string, name string) + +func Middleware(sourceService string, resolveActor ActorResolver) gin.HandlerFunc { + sourceService = strings.TrimSpace(sourceService) + return func(ctx *gin.Context) { + if ctx.Request.Method == "GET" || ctx.Request.Method == "HEAD" || ctx.Request.Method == "OPTIONS" { + ctx.Next() + return + } + + var body []byte + if ctx.Request.Body != nil { + body, _ = io.ReadAll(ctx.Request.Body) + ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + } + ctx.Next() + + actorID, actorName := "", "" + if resolveActor != nil { + actorID, actorName = resolveActor(ctx) + } + if actorID == "" { + actorID = firstNonEmpty(ctx.GetHeader("X-User-Id"), ctx.GetHeader("X-Actor-Id"), "unknown") + } + if actorName == "" { + actorName = firstNonEmpty(ctx.GetHeader("X-User-Name"), ctx.GetHeader("X-Actor-Name")) + } + result := "success" + if len(ctx.Errors) > 0 || ctx.Writer.Status() >= 400 { + result = "failed" + } + _, _ = SaveRecord(Record{ + TraceID: firstNonEmpty(ctx.GetHeader("X-Trace-Id"), ctx.GetHeader("Request-Id")), + SourceService: sourceService, + ActorID: actorID, + ActorName: actorName, + Action: ctx.Request.Method + " " + ctx.FullPath(), + ObjectType: routeObjectType(ctx.FullPath()), + ObjectID: firstNonEmpty(ctx.Param("id"), ctx.Query("id"), ctx.FullPath()), + RequestMethod: ctx.Request.Method, + RequestPath: ctx.FullPath(), + ClientIP: ctx.ClientIP(), + AfterJSON: string(body), + Result: result, + }) + } +} + +func routeObjectType(path string) string { + path = strings.Trim(path, "/") + if path == "" { + return "unknown" + } + parts := strings.Split(path, "/") + return parts[len(parts)-1] +} diff --git a/internal/logic/controllers/crud.go b/internal/logic/controllers/crud.go index 756c10a..a819d5b 100644 --- a/internal/logic/controllers/crud.go +++ b/internal/logic/controllers/crud.go @@ -1,313 +1,340 @@ -package controllers - -import ( - "errors" - "strconv" - - "git.apinb.com/bsm-sdk/core/infra" - "git.apinb.com/ops/logs/internal/impl" - "git.apinb.com/ops/logs/internal/ingest" - "git.apinb.com/ops/logs/internal/models" - "github.com/gin-gonic/gin" -) - -func parseID(ctx *gin.Context) (uint, error) { - id64, err := strconv.ParseUint(ctx.Param("id"), 10, 32) - if err != nil { - return 0, err - } - return uint(id64), nil -} - -func ListSyslogRules(ctx *gin.Context) { - var rows []models.SyslogRule - if err := impl.DBService.Order("priority desc, id asc").Find(&rows).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, gin.H{"items": rows}) -} - -func CreateSyslogRule(ctx *gin.Context) { - var row models.SyslogRule - if err := ctx.ShouldBindJSON(&row); err != nil { - infra.Response.Error(ctx, err) - return - } - row.ID = 0 - if err := impl.DBService.Create(&row).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, row) -} - -func UpdateSyslogRule(ctx *gin.Context) { - id, err := parseID(ctx) - if err != nil { - infra.Response.Error(ctx, errors.New("invalid id")) - return - } - var row models.SyslogRule - if err := impl.DBService.First(&row, id).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - if err := ctx.ShouldBindJSON(&row); err != nil { - infra.Response.Error(ctx, err) - return - } - row.ID = id - if err := impl.DBService.Save(&row).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, row) -} - -func DeleteSyslogRule(ctx *gin.Context) { - id, err := parseID(ctx) - if err != nil { - infra.Response.Error(ctx, errors.New("invalid id")) - return - } - if err := impl.DBService.Delete(&models.SyslogRule{}, id).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, gin.H{"deleted": id}) -} - -func ListTrapRules(ctx *gin.Context) { - var rows []models.TrapRule - if err := impl.DBService.Order("priority desc, id asc").Find(&rows).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, gin.H{"items": rows}) -} - -func CreateTrapRule(ctx *gin.Context) { - var row models.TrapRule - if err := ctx.ShouldBindJSON(&row); err != nil { - infra.Response.Error(ctx, err) - return - } - row.ID = 0 - if err := impl.DBService.Create(&row).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, row) -} - -func UpdateTrapRule(ctx *gin.Context) { - id, err := parseID(ctx) - if err != nil { - infra.Response.Error(ctx, errors.New("invalid id")) - return - } - var row models.TrapRule - if err := impl.DBService.First(&row, id).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - if err := ctx.ShouldBindJSON(&row); err != nil { - infra.Response.Error(ctx, err) - return - } - row.ID = id - if err := impl.DBService.Save(&row).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, row) -} - -func DeleteTrapRule(ctx *gin.Context) { - id, err := parseID(ctx) - if err != nil { - infra.Response.Error(ctx, errors.New("invalid id")) - return - } - if err := impl.DBService.Delete(&models.TrapRule{}, id).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, gin.H{"deleted": id}) -} - -func ListTrapDictionary(ctx *gin.Context) { - var rows []models.TrapDictionaryEntry - if err := impl.DBService.Order("id asc").Find(&rows).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, gin.H{"items": rows}) -} - -func CreateTrapDictionary(ctx *gin.Context) { - var row models.TrapDictionaryEntry - if err := ctx.ShouldBindJSON(&row); err != nil { - infra.Response.Error(ctx, err) - return - } - row.ID = 0 - if err := impl.DBService.Create(&row).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, row) -} - -func UpdateTrapDictionary(ctx *gin.Context) { - id, err := parseID(ctx) - if err != nil { - infra.Response.Error(ctx, errors.New("invalid id")) - return - } - var row models.TrapDictionaryEntry - if err := impl.DBService.First(&row, id).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - if err := ctx.ShouldBindJSON(&row); err != nil { - infra.Response.Error(ctx, err) - return - } - row.ID = id - if err := impl.DBService.Save(&row).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, row) -} - -func DeleteTrapDictionary(ctx *gin.Context) { - id, err := parseID(ctx) - if err != nil { - infra.Response.Error(ctx, errors.New("invalid id")) - return - } - if err := impl.DBService.Delete(&models.TrapDictionaryEntry{}, id).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, gin.H{"deleted": id}) -} - -func ListTrapShields(ctx *gin.Context) { - var rows []models.TrapShield - if err := impl.DBService.Order("id asc").Find(&rows).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, gin.H{"items": rows}) -} - -func CreateTrapShield(ctx *gin.Context) { - var row models.TrapShield - if err := ctx.ShouldBindJSON(&row); err != nil { - infra.Response.Error(ctx, err) - return - } - row.ID = 0 - if err := impl.DBService.Create(&row).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, row) -} - -func UpdateTrapShield(ctx *gin.Context) { - id, err := parseID(ctx) - if err != nil { - infra.Response.Error(ctx, errors.New("invalid id")) - return - } - var row models.TrapShield - if err := impl.DBService.First(&row, id).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - if err := ctx.ShouldBindJSON(&row); err != nil { - infra.Response.Error(ctx, err) - return - } - row.ID = id - if err := impl.DBService.Save(&row).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, row) -} - -func DeleteTrapShield(ctx *gin.Context) { - id, err := parseID(ctx) - if err != nil { - infra.Response.Error(ctx, errors.New("invalid id")) - return - } - if err := impl.DBService.Delete(&models.TrapShield{}, id).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - _ = ingest.Global.Refresh() - infra.Response.Success(ctx, gin.H{"deleted": id}) -} - -func ListLogEvents(ctx *gin.Context) { - kind := ctx.Query("source_kind") - resourceType := ctx.Query("resource_type") - resourceID := ctx.Query("resource_id") - dispatchStatus := ctx.Query("dispatch_status") - logEventID, _ := strconv.ParseUint(ctx.DefaultQuery("log_event_id", "0"), 10, 64) - page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "50")) - if page < 1 { - page = 1 - } - if size < 1 || size > 500 { - size = 50 - } - offset := (page - 1) * size - q := impl.DBService.Model(&models.LogEvent{}) - if kind != "" { - q = q.Where("source_kind = ?", kind) - } - if resourceType != "" { - q = q.Where("resource_type = ?", resourceType) - } - if resourceID != "" { - q = q.Where("resource_id = ?", resourceID) - } - if dispatchStatus != "" { - q = q.Where("dispatch_status = ?", dispatchStatus) - } - if logEventID > 0 { - q = q.Where("id = ?", uint(logEventID)) - } - var total int64 - _ = q.Count(&total).Error - var rows []models.LogEvent - if err := q.Order("id desc").Offset(offset).Limit(size).Find(&rows).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - infra.Response.Success(ctx, gin.H{"total": total, "page": page, "page_size": size, "items": rows}) -} +package controllers + +import ( + "errors" + "strconv" + + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/ops/logs/internal/impl" + "git.apinb.com/ops/logs/internal/ingest" + "git.apinb.com/ops/logs/internal/models" + "github.com/gin-gonic/gin" +) + +func parseID(ctx *gin.Context) (uint, error) { + id64, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + return 0, err + } + return uint(id64), nil +} + +func ListSyslogRules(ctx *gin.Context) { + var rows []models.SyslogRule + if err := impl.DBService.Order("priority desc, id asc").Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, gin.H{"items": rows}) +} + +func CreateSyslogRule(ctx *gin.Context) { + var row models.SyslogRule + if err := ctx.ShouldBindJSON(&row); err != nil { + infra.Response.Error(ctx, err) + return + } + row.ID = 0 + if err := impl.DBService.Create(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, row) +} + +func UpdateSyslogRule(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + var row models.SyslogRule + if err := impl.DBService.First(&row, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := ctx.ShouldBindJSON(&row); err != nil { + infra.Response.Error(ctx, err) + return + } + row.ID = id + if err := impl.DBService.Save(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, row) +} + +func DeleteSyslogRule(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + if err := impl.DBService.Delete(&models.SyslogRule{}, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, gin.H{"deleted": id}) +} + +func ListTrapRules(ctx *gin.Context) { + var rows []models.TrapRule + if err := impl.DBService.Order("priority desc, id asc").Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, gin.H{"items": rows}) +} + +func CreateTrapRule(ctx *gin.Context) { + var row models.TrapRule + if err := ctx.ShouldBindJSON(&row); err != nil { + infra.Response.Error(ctx, err) + return + } + row.ID = 0 + if err := impl.DBService.Create(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, row) +} + +func UpdateTrapRule(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + var row models.TrapRule + if err := impl.DBService.First(&row, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := ctx.ShouldBindJSON(&row); err != nil { + infra.Response.Error(ctx, err) + return + } + row.ID = id + if err := impl.DBService.Save(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, row) +} + +func DeleteTrapRule(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + if err := impl.DBService.Delete(&models.TrapRule{}, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, gin.H{"deleted": id}) +} + +func ListTrapDictionary(ctx *gin.Context) { + var rows []models.TrapDictionaryEntry + if err := impl.DBService.Order("id asc").Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, gin.H{"items": rows}) +} + +func CreateTrapDictionary(ctx *gin.Context) { + var row models.TrapDictionaryEntry + if err := ctx.ShouldBindJSON(&row); err != nil { + infra.Response.Error(ctx, err) + return + } + row.ID = 0 + if err := impl.DBService.Create(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, row) +} + +func UpdateTrapDictionary(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + var row models.TrapDictionaryEntry + if err := impl.DBService.First(&row, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := ctx.ShouldBindJSON(&row); err != nil { + infra.Response.Error(ctx, err) + return + } + row.ID = id + if err := impl.DBService.Save(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, row) +} + +func DeleteTrapDictionary(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + if err := impl.DBService.Delete(&models.TrapDictionaryEntry{}, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, gin.H{"deleted": id}) +} + +func ListTrapShields(ctx *gin.Context) { + var rows []models.TrapShield + if err := impl.DBService.Order("id asc").Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, gin.H{"items": rows}) +} + +func CreateTrapShield(ctx *gin.Context) { + var row models.TrapShield + if err := ctx.ShouldBindJSON(&row); err != nil { + infra.Response.Error(ctx, err) + return + } + row.ID = 0 + if err := impl.DBService.Create(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, row) +} + +func UpdateTrapShield(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + var row models.TrapShield + if err := impl.DBService.First(&row, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := ctx.ShouldBindJSON(&row); err != nil { + infra.Response.Error(ctx, err) + return + } + row.ID = id + if err := impl.DBService.Save(&row).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, row) +} + +func DeleteTrapShield(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + if err := impl.DBService.Delete(&models.TrapShield{}, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + _ = ingest.Global.Refresh() + infra.Response.Success(ctx, gin.H{"deleted": id}) +} + +func ListLogEvents(ctx *gin.Context) { + kind := ctx.Query("source_kind") + resourceType := ctx.Query("resource_type") + resourceID := ctx.Query("resource_id") + dispatchStatus := ctx.Query("dispatch_status") + logEventID, _ := strconv.ParseUint(ctx.DefaultQuery("log_event_id", "0"), 10, 64) + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + if size < 1 || size > 500 { + size = 50 + } + offset := (page - 1) * size + q := impl.DBService.Model(&models.LogEvent{}) + if kind != "" { + q = q.Where("source_kind = ?", kind) + } + if resourceType != "" { + q = q.Where("resource_type = ?", resourceType) + } + if resourceID != "" { + q = q.Where("resource_id = ?", resourceID) + } + if dispatchStatus != "" { + q = q.Where("dispatch_status = ?", dispatchStatus) + } + if logEventID > 0 { + q = q.Where("id = ?", uint(logEventID)) + } + var total int64 + _ = q.Count(&total).Error + var rows []models.LogEvent + if err := q.Order("id desc").Offset(offset).Limit(size).Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "page": page, "page_size": size, "items": rows}) +} + +func ReplayLogEvent(ctx *gin.Context) { + id, err := parseID(ctx) + if err != nil { + infra.Response.Error(ctx, errors.New("invalid id")) + return + } + var row models.LogEvent + if err := impl.DBService.First(&row, id).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + outboxID, err := ingest.EnqueueReplayLogEvent(row) + if err != nil { + infra.Response.Error(ctx, err) + return + } + if err := impl.DBService.Model(&models.LogEvent{}).Where("id = ?", id).Update("dispatch_status", "pending").Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{ + "log_event_id": id, + "outbox_id": outboxID, + "status": "pending", + }) +} diff --git a/internal/models/audit_log.go b/internal/models/audit_log.go new file mode 100644 index 0000000..fbccd7e --- /dev/null +++ b/internal/models/audit_log.go @@ -0,0 +1,28 @@ +package models + +import "time" + +type AuditLog struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + TraceID string `gorm:"size:96;index" json:"trace_id"` + SourceService string `gorm:"size:64;index" json:"source_service"` + ActorID string `gorm:"size:128;index" json:"actor_id"` + ActorName string `gorm:"size:128" json:"actor_name"` + Action string `gorm:"size:128;index" json:"action"` + ObjectType string `gorm:"size:128;index" json:"object_type"` + ObjectID string `gorm:"size:128;index" json:"object_id"` + OperationRisk string `gorm:"size:32;index" json:"operation_risk"` + ApprovalID string `gorm:"size:128;index" json:"approval_id"` + RequestMethod string `gorm:"size:16" json:"request_method"` + RequestPath string `gorm:"size:512" json:"request_path"` + ClientIP string `gorm:"size:64" json:"client_ip"` + BeforeJSON string `gorm:"type:text" json:"before_json"` + AfterJSON string `gorm:"type:text" json:"after_json"` + Result string `gorm:"size:32;index" json:"result"` + ErrorMessage string `gorm:"type:text" json:"error_message"` +} + +func (AuditLog) TableName() string { + return "logs_audit_logs" +} diff --git a/internal/models/blueprint_fields_test.go b/internal/models/blueprint_fields_test.go new file mode 100644 index 0000000..56e6e8c --- /dev/null +++ b/internal/models/blueprint_fields_test.go @@ -0,0 +1,24 @@ +package models + +import ( + "reflect" + "testing" +) + +func TestTrapDictionaryEntryHasBlueprintFields(t *testing.T) { + typ := reflect.TypeOf(TrapDictionaryEntry{}) + for _, name := range []string{"Vendor", "OID", "Name", "SeverityMappingJSON", "ParseExpression"} { + if _, ok := typ.FieldByName(name); !ok { + t.Fatalf("TrapDictionaryEntry missing blueprint field %s", name) + } + } +} + +func TestSyslogRuleHasBlueprintFields(t *testing.T) { + typ := reflect.TypeOf(SyslogRule{}) + for _, name := range []string{"SourceMatch", "MessageRegex", "SeverityMappingJSON", "ResourceUIDExtractRegex"} { + if _, ok := typ.FieldByName(name); !ok { + t.Fatalf("SyslogRule missing blueprint field %s", name) + } + } +} diff --git a/internal/models/dangerous_operation_approval.go b/internal/models/dangerous_operation_approval.go new file mode 100644 index 0000000..b0d17ac --- /dev/null +++ b/internal/models/dangerous_operation_approval.go @@ -0,0 +1,28 @@ +package models + +import "time" + +type DangerousOperationApproval struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RequestID string `gorm:"size:128;uniqueIndex" json:"request_id"` + SourceService string `gorm:"size:64;index" json:"source_service"` + Action string `gorm:"size:128;index" json:"action"` + ObjectType string `gorm:"size:128;index" json:"object_type"` + ObjectID string `gorm:"size:128;index" json:"object_id"` + RequesterID string `gorm:"size:128;index" json:"requester_id"` + RequesterName string `gorm:"size:128" json:"requester_name"` + Reason string `gorm:"type:text" json:"reason"` + BeforeJSON string `gorm:"type:text" json:"before_json"` + AfterJSON string `gorm:"type:text" json:"after_json"` + Status string `gorm:"size:32;index" json:"status"` + ReviewerID string `gorm:"size:128;index" json:"reviewer_id"` + ReviewerName string `gorm:"size:128" json:"reviewer_name"` + ReviewComment string `gorm:"type:text" json:"review_comment"` + ReviewedAt *time.Time `json:"reviewed_at"` +} + +func (DangerousOperationApproval) TableName() string { + return "logs_dangerous_operation_approvals" +} diff --git a/internal/models/query.go b/internal/models/query.go index 411f9e7..ee6a287 100644 --- a/internal/models/query.go +++ b/internal/models/query.go @@ -1,119 +1,147 @@ -package models - -import "gorm.io/gorm" - -// GetAllModels 数据库迁移用模型列表 -func GetAllModels() []interface{} { - return []interface{}{ - &LogEvent{}, - &AlertOutbox{}, - &ResourceMapping{}, - &ResourceEventDedup{}, - &TrapDictionaryEntry{}, - &SyslogRule{}, - &TrapRule{}, - &TrapShield{}, - } -} - -// InitData 初始化默认规则数据(幂等) -func InitData(db *gorm.DB) error { - if db == nil { - return nil - } - if err := seedDefaultSyslogRules(db); err != nil { - return err - } - if err := seedDefaultTrapRules(db); err != nil { - return err - } - if err := seedDefaultTrapDictionary(db); err != nil { - return err - } - return nil -} - -func seedDefaultSyslogRules(db *gorm.DB) error { - var cnt int64 - if err := db.Model(&SyslogRule{}).Count(&cnt).Error; err != nil { - return err - } - if cnt > 0 { - return nil - } - rows := []SyslogRule{ - { - Name: "默认-系统严重错误", - Enabled: true, - Priority: 100, - DeviceNameContains: "", - KeywordRegex: "(?i)(panic|fatal|segmentation fault|kernel panic|out of memory|oom)", - AlertName: "Syslog严重错误", - SeverityCode: "critical", - PolicyID: 0, - }, - { - Name: "默认-链路中断告警", - Enabled: true, - Priority: 90, - DeviceNameContains: "", - KeywordRegex: "(?i)(link down|interface .* down|port .* down)", - AlertName: "Syslog链路中断", - SeverityCode: "major", - PolicyID: 0, - }, - } - return db.Create(&rows).Error -} - -func seedDefaultTrapRules(db *gorm.DB) error { - var cnt int64 - if err := db.Model(&TrapRule{}).Count(&cnt).Error; err != nil { - return err - } - if cnt > 0 { - return nil - } - rows := []TrapRule{ - { - Name: "默认-Trap链路中断", - Enabled: true, - Priority: 100, - OIDPrefix: "1.3.6.1.6.3.1.1.5", - VarbindMatchRegex: "(?i)(linkdown|ifdown|down)", - AlertName: "SNMP Trap链路中断", - SeverityCode: "major", - PolicyID: 0, - }, - } - return db.Create(&rows).Error -} - -func seedDefaultTrapDictionary(db *gorm.DB) error { - var cnt int64 - if err := db.Model(&TrapDictionaryEntry{}).Count(&cnt).Error; err != nil { - return err - } - if cnt > 0 { - return nil - } - rows := []TrapDictionaryEntry{ - { - OIDPrefix: "1.3.6.1.6.3.1.1.5.3", - Title: "ifDown 接口中断", - Description: "检测到设备接口状态变为 down。", - SeverityCode: "major", - RecoveryMessage: "请检查链路、端口状态和对端设备。", - Enabled: true, - }, - { - OIDPrefix: "1.3.6.1.6.3.1.1.5.4", - Title: "ifUp 接口恢复", - Description: "检测到设备接口状态恢复为 up。", - SeverityCode: "info", - RecoveryMessage: "接口已恢复,请确认业务连通性。", - Enabled: true, - }, - } - return db.Create(&rows).Error -} +package models + +import "gorm.io/gorm" + +// GetAllModels 数据库迁移用模型列表 +func GetAllModels() []interface{} { + return []interface{}{ + &LogEvent{}, + &AlertOutbox{}, + &ResourceMapping{}, + &ResourceEventDedup{}, + &TrapDictionaryEntry{}, + &SyslogRule{}, + &TrapRule{}, + &TrapShield{}, + &AuditLog{}, + &DangerousOperationApproval{}, + } +} + +// InitData 初始化默认规则数据(幂等) +func InitData(db *gorm.DB) error { + if db == nil { + return nil + } + if err := seedDefaultSyslogRules(db); err != nil { + return err + } + if err := seedDefaultTrapRules(db); err != nil { + return err + } + if err := seedDefaultTrapDictionary(db); err != nil { + return err + } + return nil +} + +func seedDefaultSyslogRules(db *gorm.DB) error { + var cnt int64 + if err := db.Model(&SyslogRule{}).Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return nil + } + rows := []SyslogRule{ + { + Name: "默认-系统严重错误", + Enabled: true, + Priority: 100, + DeviceNameContains: "", + KeywordRegex: "(?i)(panic|fatal|segmentation fault|kernel panic|out of memory|oom)", + AlertName: "Syslog严重错误", + SeverityCode: "critical", + PolicyID: 0, + }, + { + Name: "默认-链路中断告警", + Enabled: true, + Priority: 90, + DeviceNameContains: "", + KeywordRegex: "(?i)(link down|interface .* down|port .* down)", + SourceMatch: "", + MessageRegex: "(?i)(link down|interface .* down|port .* down|LINK_DOWN)", + AlertName: "Syslog链路中断", + SeverityCode: "major", + SeverityMappingJSON: `{"(?i)(critical|fatal|emergency)":"critical","(?i)(error|LINK_DOWN|down)":"major","(?i)(warning|warn)":"warning"}`, + ResourceUIDExtractRegex: `(?i)(?:resource_uid=|resource=)(?P[a-z0-9_-]+:[a-z0-9_.:/-]+)|Interface (?P[A-Za-z0-9/._-]+)`, + PolicyID: 0, + }, + { + Name: "H3C-Syslog-接口中断", + Enabled: true, + Priority: 120, + SourceMatch: "h3c", + MessageRegex: `(?i)(LINK_DOWN|Interface .* down|port .* down)`, + AlertName: "H3C Syslog接口中断", + SeverityCode: "major", + SeverityMappingJSON: `{"(?i)(LINK_DOWN|down)":"major","(?i)(LINK_UP|up)":"info"}`, + ResourceUIDExtractRegex: `(?i)(?:resource_uid=|resource=)(?Pnetwork:[a-z0-9_.:/-]+)|Interface (?P[A-Za-z0-9/._-]+)`, + PolicyID: 0, + }, + } + return db.Create(&rows).Error +} + +func seedDefaultTrapRules(db *gorm.DB) error { + var cnt int64 + if err := db.Model(&TrapRule{}).Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return nil + } + rows := []TrapRule{ + { + Name: "默认-Trap链路中断", + Enabled: true, + Priority: 100, + OIDPrefix: "1.3.6.1.6.3.1.1.5", + VarbindMatchRegex: "(?i)(linkdown|ifdown|down)", + AlertName: "SNMP Trap链路中断", + SeverityCode: "major", + PolicyID: 0, + }, + } + return db.Create(&rows).Error +} + +func seedDefaultTrapDictionary(db *gorm.DB) error { + var cnt int64 + if err := db.Model(&TrapDictionaryEntry{}).Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return nil + } + rows := []TrapDictionaryEntry{ + { + Vendor: "H3C", + OID: "1.3.6.1.6.3.1.1.5.3", + OIDPrefix: "1.3.6.1.6.3.1.1.5.3", + Name: "H3C ifDown 接口中断", + Title: "ifDown 接口中断", + Description: "检测到设备接口状态变为 down。", + SeverityCode: "major", + SeverityMappingJSON: `{"down":"major","up":"info"}`, + ParseExpression: `(?i)(ifName|interface)=?(?P[A-Za-z0-9/._-]+)`, + RecoveryMessage: "请检查链路、端口状态和对端设备。", + Enabled: true, + }, + { + Vendor: "H3C", + OID: "1.3.6.1.6.3.1.1.5.4", + OIDPrefix: "1.3.6.1.6.3.1.1.5.4", + Name: "H3C ifUp 接口恢复", + Title: "ifUp 接口恢复", + Description: "检测到设备接口状态恢复为 up。", + SeverityCode: "info", + SeverityMappingJSON: `{"up":"info"}`, + ParseExpression: `(?i)(ifName|interface)=?(?P[A-Za-z0-9/._-]+)`, + RecoveryMessage: "接口已恢复,请确认业务连通性。", + Enabled: true, + }, + } + return db.Create(&rows).Error +} diff --git a/internal/models/syslog_rule.go b/internal/models/syslog_rule.go index 806a27e..bf1f60b 100644 --- a/internal/models/syslog_rule.go +++ b/internal/models/syslog_rule.go @@ -1,33 +1,41 @@ -package models - -import "time" - -// SyslogRule 表示一条 Syslog 规则,用于匹配设备日志并触发告警。 -type SyslogRule struct { - // ID 是数据库主键。 - ID uint `gorm:"primaryKey" json:"id"` - // CreatedAt 记录创建时间(GORM 自动维护)。 - CreatedAt time.Time `json:"created_at"` - // UpdatedAt 记录更新时间(GORM 自动维护)。 - UpdatedAt time.Time `json:"updated_at"` - // Name 规则名称,用于展示/标识。 - Name string `gorm:"size:256" json:"name"` - // Enabled 表示该规则是否启用。 - Enabled bool `gorm:"default:true" json:"enabled"` - // Priority 表示匹配优先级(数值越高/低需以业务约定为准)。 - Priority int `gorm:"index" json:"priority"` - // DeviceNameContains 表示设备名称包含条件。 - DeviceNameContains string `gorm:"size:512" json:"device_name_contains"` - // KeywordRegex 表示关键字/内容匹配的正则表达式。 - KeywordRegex string `gorm:"size:512" json:"keyword_regex"` - // AlertName 表示告警名称。 - AlertName string `gorm:"size:256" json:"alert_name"` - // SeverityCode 表示严重级别编码。 - SeverityCode string `gorm:"size:32" json:"severity_code"` - // PolicyID 表示关联的告警/处理策略 ID。 - PolicyID uint `json:"policy_id"` -} - -func (SyslogRule) TableName() string { - return "logs_syslog_rules" -} +package models + +import "time" + +// SyslogRule 表示一条 Syslog 规则,用于匹配设备日志并触发告警。 +type SyslogRule struct { + // ID 是数据库主键。 + ID uint `gorm:"primaryKey" json:"id"` + // CreatedAt 记录创建时间(GORM 自动维护)。 + CreatedAt time.Time `json:"created_at"` + // UpdatedAt 记录更新时间(GORM 自动维护)。 + UpdatedAt time.Time `json:"updated_at"` + // Name 规则名称,用于展示/标识。 + Name string `gorm:"size:256" json:"name"` + // Enabled 表示该规则是否启用。 + Enabled bool `gorm:"default:true" json:"enabled"` + // Priority 表示匹配优先级(数值越高/低需以业务约定为准)。 + Priority int `gorm:"index" json:"priority"` + // DeviceNameContains 表示设备名称包含条件。 + DeviceNameContains string `gorm:"size:512" json:"device_name_contains"` + // SourceMatch 表示来源匹配条件,可匹配来源 IP、主机名或原始行。 + SourceMatch string `gorm:"size:512" json:"source_match"` + // KeywordRegex 表示关键字/内容匹配的正则表达式。 + KeywordRegex string `gorm:"size:512" json:"keyword_regex"` + // MessageRegex 表示消息正文匹配的正则表达式。 + MessageRegex string `gorm:"size:1024" json:"message_regex"` + // AlertName 表示告警名称。 + AlertName string `gorm:"size:256" json:"alert_name"` + // SeverityCode 表示严重级别编码。 + SeverityCode string `gorm:"size:32" json:"severity_code"` + // SeverityMappingJSON 保存按正则分组或厂商级别映射到平台级别的 JSON。 + SeverityMappingJSON string `gorm:"type:text" json:"severity_mapping_json"` + // ResourceUIDExtractRegex 表示从消息中提取 resource_uid 的正则。 + ResourceUIDExtractRegex string `gorm:"size:1024" json:"resource_uid_extract_regex"` + // PolicyID 表示关联的告警/处理策略 ID。 + PolicyID uint `json:"policy_id"` +} + +func (SyslogRule) TableName() string { + return "logs_syslog_rules" +} diff --git a/internal/models/trap_dictionary.go b/internal/models/trap_dictionary.go index 90d1abf..63a462e 100644 --- a/internal/models/trap_dictionary.go +++ b/internal/models/trap_dictionary.go @@ -1,29 +1,39 @@ -package models - -import "time" - -// TrapDictionaryEntry 表示 Trap 字典条目,用于描述某个 OID 前缀对应的告警元信息。 -type TrapDictionaryEntry struct { - // ID 是数据库主键。 - ID uint `gorm:"primaryKey" json:"id"` - // CreatedAt 记录创建时间(GORM 自动维护)。 - CreatedAt time.Time `json:"created_at"` - // UpdatedAt 记录更新时间(GORM 自动维护)。 - UpdatedAt time.Time `json:"updated_at"` - // OIDPrefix 表示该字典条目对应的 OID 前缀(唯一)。 - OIDPrefix string `gorm:"size:512;uniqueIndex" json:"oid_prefix"` - // Title 表示字典条目的标题。 - Title string `gorm:"size:512" json:"title"` - // Description 表示字典条目的说明文本。 - Description string `gorm:"type:text" json:"description"` - // SeverityCode 表示默认严重级别编码。 - SeverityCode string `gorm:"size:32" json:"severity_code"` - // RecoveryMessage 表示恢复/消警时的消息模板内容。 - RecoveryMessage string `gorm:"type:text" json:"recovery_message"` - // Enabled 表示该字典条目是否启用。 - Enabled bool `gorm:"default:true" json:"enabled"` -} - -func (TrapDictionaryEntry) TableName() string { - return "logs_trap_dictionary" -} +package models + +import "time" + +// TrapDictionaryEntry 表示 Trap 字典条目,用于描述某个 OID 前缀对应的告警元信息。 +type TrapDictionaryEntry struct { + // ID 是数据库主键。 + ID uint `gorm:"primaryKey" json:"id"` + // CreatedAt 记录创建时间(GORM 自动维护)。 + CreatedAt time.Time `json:"created_at"` + // UpdatedAt 记录更新时间(GORM 自动维护)。 + UpdatedAt time.Time `json:"updated_at"` + // OIDPrefix 表示该字典条目对应的 OID 前缀(唯一)。 + OIDPrefix string `gorm:"size:512;uniqueIndex" json:"oid_prefix"` + // Vendor 表示设备厂商,例如 H3C、Huawei、Cisco。 + Vendor string `gorm:"size:128;index" json:"vendor"` + // OID 表示精确 Trap OID。为空时继续使用 OIDPrefix 做前缀匹配。 + OID string `gorm:"size:512;index" json:"oid"` + // Name 表示 Trap 字典名称;保留 Title 作为旧页面兼容字段。 + Name string `gorm:"size:512" json:"name"` + // Title 表示字典条目的标题。 + Title string `gorm:"size:512" json:"title"` + // Description 表示字典条目的说明文本。 + Description string `gorm:"type:text" json:"description"` + // SeverityCode 表示默认严重级别编码。 + SeverityCode string `gorm:"size:32" json:"severity_code"` + // SeverityMappingJSON 保存按 varbind 或厂商级别映射到平台级别的 JSON。 + SeverityMappingJSON string `gorm:"type:text" json:"severity_mapping_json"` + // ParseExpression 表示解析 varbind 的表达式或正则模板。 + ParseExpression string `gorm:"type:text" json:"parse_expression"` + // RecoveryMessage 表示恢复/消警时的消息模板内容。 + RecoveryMessage string `gorm:"type:text" json:"recovery_message"` + // Enabled 表示该字典条目是否启用。 + Enabled bool `gorm:"default:true" json:"enabled"` +} + +func (TrapDictionaryEntry) TableName() string { + return "logs_trap_dictionary" +} diff --git a/internal/routers/register.go b/internal/routers/register.go index 1c406d7..8228d3a 100644 --- a/internal/routers/register.go +++ b/internal/routers/register.go @@ -1,48 +1,57 @@ -package routers - -import ( - "fmt" - - "git.apinb.com/bsm-sdk/core/middleware" - "git.apinb.com/ops/logs/internal/logic/controllers" - "git.apinb.com/ops/logs/internal/logic/ping" - "github.com/gin-gonic/gin" -) - -func Register(srvKey string, engine *gin.Engine) { - v1 := fmt.Sprintf("/%s/%s", srvKey, "v1") - anon := engine.Group(v1) - { - anon.GET("/ping/hello", ping.Hello) - } - - api := engine.Group(v1) - api.Use(middleware.JwtAuth(true)) - { - api.GET("/syslog-rules", controllers.ListSyslogRules) - api.POST("/syslog-rules", controllers.CreateSyslogRule) - api.PUT("/syslog-rules/:id", controllers.UpdateSyslogRule) - api.DELETE("/syslog-rules/:id", controllers.DeleteSyslogRule) - - api.GET("/trap-rules", controllers.ListTrapRules) - api.POST("/trap-rules", controllers.CreateTrapRule) - api.PUT("/trap-rules/:id", controllers.UpdateTrapRule) - api.DELETE("/trap-rules/:id", controllers.DeleteTrapRule) - - api.GET("/trap-dictionary", controllers.ListTrapDictionary) - api.POST("/trap-dictionary", controllers.CreateTrapDictionary) - api.PUT("/trap-dictionary/:id", controllers.UpdateTrapDictionary) - api.DELETE("/trap-dictionary/:id", controllers.DeleteTrapDictionary) - - api.GET("/trap-suppressions", controllers.ListTrapShields) - api.POST("/trap-suppressions", controllers.CreateTrapShield) - api.PUT("/trap-suppressions/:id", controllers.UpdateTrapShield) - api.DELETE("/trap-suppressions/:id", controllers.DeleteTrapShield) - - api.POST("/resource-events", controllers.ReceiveResourceEvent) - - api.GET("/entries", controllers.ListLogEvents) - api.GET("/alert-outbox", controllers.ListAlertOutbox) - api.POST("/alert-outbox/:id/retry", controllers.RetryAlertOutbox) - } -} +package routers + +import ( + "fmt" + + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/ops/logs/internal/logic/audit" + "git.apinb.com/ops/logs/internal/logic/controllers" + "git.apinb.com/ops/logs/internal/logic/ping" + "github.com/gin-gonic/gin" +) + +func Register(srvKey string, engine *gin.Engine) { + v1 := fmt.Sprintf("/%s/%s", srvKey, "v1") + anon := engine.Group(v1) + { + anon.GET("/ping/hello", ping.Hello) + } + + api := engine.Group(v1) + api.Use(middleware.JwtAuth(true)) + { + api.GET("/syslog-rules", controllers.ListSyslogRules) + api.POST("/syslog-rules", controllers.CreateSyslogRule) + api.PUT("/syslog-rules/:id", controllers.UpdateSyslogRule) + api.DELETE("/syslog-rules/:id", controllers.DeleteSyslogRule) + + api.GET("/trap-rules", controllers.ListTrapRules) + api.POST("/trap-rules", controllers.CreateTrapRule) + api.PUT("/trap-rules/:id", controllers.UpdateTrapRule) + api.DELETE("/trap-rules/:id", controllers.DeleteTrapRule) + + api.GET("/trap-dictionary", controllers.ListTrapDictionary) + api.POST("/trap-dictionary", controllers.CreateTrapDictionary) + api.PUT("/trap-dictionary/:id", controllers.UpdateTrapDictionary) + api.DELETE("/trap-dictionary/:id", controllers.DeleteTrapDictionary) + + api.GET("/trap-suppressions", controllers.ListTrapShields) + api.POST("/trap-suppressions", controllers.CreateTrapShield) + api.PUT("/trap-suppressions/:id", controllers.UpdateTrapShield) + api.DELETE("/trap-suppressions/:id", controllers.DeleteTrapShield) + + api.POST("/resource-events", controllers.ReceiveResourceEvent) + + api.GET("/entries", controllers.ListLogEvents) + api.POST("/entries/:id/replay", controllers.ReplayLogEvent) + api.GET("/alert-outbox", controllers.ListAlertOutbox) + api.POST("/alert-outbox/:id/retry", controllers.RetryAlertOutbox) + + api.GET("/audit/logs", audit.ListAuditLogs) + api.POST("/audit/logs", audit.CreateAuditLog) + api.GET("/audit/approvals", audit.ListApprovals) + api.POST("/audit/approvals", audit.CreateApproval) + api.POST("/audit/approvals/:id/approve", audit.ApproveApproval) + api.POST("/audit/approvals/:id/reject", audit.RejectApproval) + } +}