Files
BackupX/server/internal/service/audit_service.go
Wu Qing 5021fe665e 功能: v2.1 可观测性与流控 (#47)
* 功能: v2.1 可观测性与流控 — Prometheus + 节点带宽 + 审计 Webhook

核心能力:
- Prometheus /metrics 端点:11 类指标(任务/存储/节点/SLA/验证/恢复/复制)
- 节点级带宽限速生效:model.Node.BandwidthLimit 覆盖全局默认
- 审计日志 Webhook 外输:HMAC-SHA256 签名,配合 SIEM 合规留档

实现:
- server/internal/metrics/  独立 Registry + 异步 Gauge Collector(30s)
- backup/restore/verify/replication 服务注入 metrics 钩子,nil 安全
- resolveProviderForNode() 按 task.NodeID 解析 BandwidthLimit
- AuditService.SetWebhook + 动态 settings 推送,无需重启

测试:
- metrics/registry_test.go: 注册/采集/nil safety/HTTP handler
- service/audit_service_webhook_test.go: 签名正确性/异步投递/禁用路径
- go test ./... 全部通过

* chore: 触发 CodeQL 扫描
2026-04-20 23:26:04 +08:00

181 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
// AuditEntry 是记录审计日志的输入结构
type AuditEntry struct {
UserID uint
Username string
Category string // auth / storage_target / backup_task / backup_record / settings
Action string // create / update / delete / login_success / login_failed / ...
TargetType string
TargetID string
TargetName string
Detail string
ClientIP string
}
type AuditService struct {
repo repository.AuditLogRepository
// webhook 外输配置(可选)
webhookMu sync.RWMutex
webhookURL string
webhookSecret string
httpClient *http.Client
}
func NewAuditService(repo repository.AuditLogRepository) *AuditService {
return &AuditService{
repo: repo,
httpClient: &http.Client{
Timeout: 3 * time.Second, // 短超时:审计 webhook 不应拖慢业务
},
}
}
// SetWebhook 动态配置审计事件转发 URL 与签名密钥。
// - url 为空字符串时禁用转发
// - secret 非空时对 payload 计算 HMAC-SHA256作为 X-BackupX-Signature header
//
// 适用场景:
// - 企业 SIEM 集成Splunk HEC、ELK、Loki
// - 安全审计留痕到第三方 WORM 存储
// - 合规日志归档GDPR / SOC2
func (s *AuditService) SetWebhook(url, secret string) {
if s == nil {
return
}
s.webhookMu.Lock()
defer s.webhookMu.Unlock()
s.webhookURL = strings.TrimSpace(url)
s.webhookSecret = strings.TrimSpace(secret)
}
// Record 异步 fire-and-forget 写入审计日志,不阻塞业务逻辑
func (s *AuditService) Record(entry AuditEntry) {
if s == nil || s.repo == nil {
return
}
go func() {
record := &model.AuditLog{
UserID: entry.UserID,
Username: entry.Username,
Category: entry.Category,
Action: entry.Action,
TargetType: entry.TargetType,
TargetID: entry.TargetID,
TargetName: entry.TargetName,
Detail: entry.Detail,
ClientIP: entry.ClientIP,
}
if err := s.repo.Create(context.Background(), record); err != nil {
log.Printf("[audit] failed to write audit log: %v", err)
}
s.fireWebhook(record)
}()
}
// fireWebhook 异步向外部系统转发审计事件。失败降级到本地日志,永不影响主流程。
func (s *AuditService) fireWebhook(record *model.AuditLog) {
if s == nil {
return
}
s.webhookMu.RLock()
url := s.webhookURL
secret := s.webhookSecret
s.webhookMu.RUnlock()
if url == "" {
return
}
payload := map[string]any{
"eventType": "audit.log",
"occurredAt": record.CreatedAt.UTC().Format(time.RFC3339),
"actor": map[string]any{
"userId": record.UserID,
"username": record.Username,
},
"category": record.Category,
"action": record.Action,
"targetType": record.TargetType,
"targetId": record.TargetID,
"targetName": record.TargetName,
"detail": record.Detail,
"clientIp": record.ClientIP,
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("[audit] webhook marshal failed: %v", err)
return
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(body))
if err != nil {
log.Printf("[audit] webhook build request failed: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "BackupX-Audit/1.0")
if secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
req.Header.Set("X-BackupX-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
resp, err := s.httpClient.Do(req)
if err != nil {
log.Printf("[audit] webhook POST failed: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Printf("[audit] webhook returned status %d", resp.StatusCode)
}
}
// List 分页查询审计日志
func (s *AuditService) List(ctx context.Context, category string, limit, offset int) (*repository.AuditLogListResult, error) {
result, err := s.repo.List(ctx, repository.AuditLogListOptions{
Category: category,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, apperror.Internal("AUDIT_LOG_LIST_FAILED", fmt.Sprintf("无法获取审计日志列表: %v", err), err)
}
return result, nil
}
// ListAdvanced 多字段筛选分页查询(合规审计常用)。
func (s *AuditService) ListAdvanced(ctx context.Context, opts repository.AuditLogListOptions) (*repository.AuditLogListResult, error) {
result, err := s.repo.List(ctx, opts)
if err != nil {
return nil, apperror.Internal("AUDIT_LOG_LIST_FAILED", fmt.Sprintf("无法获取审计日志: %v", err), err)
}
return result, nil
}
// ExportAll 返回指定筛选条件下的全部审计日志(最多 10000 条),用于 CSV 导出。
func (s *AuditService) ExportAll(ctx context.Context, opts repository.AuditLogListOptions) ([]model.AuditLog, error) {
items, err := s.repo.ListAll(ctx, opts)
if err != nil {
return nil, apperror.Internal("AUDIT_LOG_EXPORT_FAILED", fmt.Sprintf("无法导出审计日志: %v", err), err)
}
return items, nil
}