diff --git a/backend/middleware/audit.go b/backend/middleware/audit.go index e254929..b822751 100644 --- a/backend/middleware/audit.go +++ b/backend/middleware/audit.go @@ -171,11 +171,19 @@ func inferAuditFields(method, path string) (module, action, resource, resourceID segments := strings.Split(strings.Trim(path, "/"), "/") // /api/v1/// + // + // ⚠️ 每个下标必须单独判断长度。曾经写成: + // if len(segments) >= 4 { resource = segments[3]; resourceID = segments[4] } + // 而 /api/v1/hosts/2 正好只有 4 段([api v1 hosts 2]), + // segments[4] 直接 index out of range → 中间件 panic → + // 所有「4 段路径」的写请求 500 且不落审计(线上实际发生过)。 if len(segments) >= 3 { module = segments[2] } if len(segments) >= 4 { resource = segments[3] + } + if len(segments) >= 5 { resourceID = segments[4] } diff --git a/backend/middleware/audit_test.go b/backend/middleware/audit_test.go new file mode 100644 index 0000000..960df3a --- /dev/null +++ b/backend/middleware/audit_test.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "net/http" + "testing" +) + +// 回归:inferAuditFields 不能越界。 +// +// 线上事故:/api/v1/hosts/2 拆出 4 段(api v1 hosts 2), +// 旧实现 `if len>=4 { resource=segments[3]; resourceID=segments[4] }` +// 直接 index out of range [4] with length 4 → 中间件 panic → +// 该路径上的所有写请求 500 且不落审计。 +// 这类 bug 编译期发现不了,必须用表驱动测试把各段数都覆盖一遍。 +func TestInferAuditFieldsNoPanic(t *testing.T) { + cases := []string{ + "/api/v1", // 2 段 + "/api/v1/hosts", // 3 段 + "/api/v1/hosts/2", // 4 段 ← 原 bug 触发点 + "/api/v1/hosts/2/validate", // 5 段 + "/api/v1/deployments/1/deploy", // 5 段 + "/api/v1/logs/policy-groups/3", // 5 段 + "/api/v1/collectors/2/install", // 5 段 + "/", // 1 段 + "", // 0 段 + } + for _, path := range cases { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("inferAuditFields(%q) panic: %v", path, r) + } + }() + inferAuditFields(http.MethodPost, path) + inferAuditFields(http.MethodPut, path) + inferAuditFields(http.MethodDelete, path) + }() + } +} + +// 4 段路径必须能解析出 module 与 resource,且 resourceID 为空(不是越界值) +func TestInferAuditFieldsFourSegments(t *testing.T) { + module, _, resource, resourceID := inferAuditFields(http.MethodPut, "/api/v1/hosts/2") + if module != "hosts" { + t.Fatalf("module = %q,期望 hosts", module) + } + if resource != "2" { + // 4 段时第 4 段按位置是 resource + t.Fatalf("resource = %q,期望 2", resource) + } + if resourceID != "" { + t.Fatalf("4 段路径不应解析出 resourceID,实际 %q", resourceID) + } +} + +// 5 段路径要正确解出 resourceID +func TestInferAuditFieldsFiveSegments(t *testing.T) { + module, action, resource, resourceID := inferAuditFields(http.MethodPost, "/api/v1/logs/sources/7") + if module != "logs" || resource != "sources" || resourceID != "7" { + t.Fatalf("解析错误: module=%q resource=%q resourceID=%q", module, resource, resourceID) + } + if action != "create" { + t.Fatalf("action = %q,期望 create", action) + } +} + +// 语义化动作不能被位置解析覆盖 +func TestInferAuditFieldsSemanticActions(t *testing.T) { + cases := map[string]string{ + "/api/v1/deployments/1/deploy": "deploy", + "/api/v1/deployments/1/stop": "stop", + "/api/v1/collectors/2/install": "install", + "/api/v1/collectors/2/uninstall": "uninstall", + "/api/v1/users/3/reset-password": "reset-password", + "/api/v1/hosts/2/validate": "validate", + "/api/v1/storage/1/test": "test", + } + for path, want := range cases { + if _, action, _, _ := inferAuditFields(http.MethodPost, path); action != want { + t.Fatalf("%s action = %q,期望 %q", path, action, want) + } + } +} + +// shouldAudit:只读接口不记,但部署/安装类动作必须记 +func TestShouldAudit(t *testing.T) { + mustAudit := []string{ + "/api/v1/deployments/1/deploy", + "/api/v1/collectors/2/install", + "/api/v1/hosts/2/validate", + "/api/v1/storage", + } + for _, p := range mustAudit { + if !shouldAudit(p) { + t.Fatalf("%q 应当被审计", p) + } + } + mustSkip := []string{ + "/api/v1/logs/query", + "/api/v1/logs/stats", + "/api/v1/health", + "/api/v1/metrics", + } + for _, p := range mustSkip { + if shouldAudit(p) { + t.Fatalf("%q 不应被审计(只读/噪音)", p) + } + } +} diff --git a/backend/services/backup.go b/backend/services/backup.go index 4b7d2d8..f429e2b 100644 --- a/backend/services/backup.go +++ b/backend/services/backup.go @@ -61,15 +61,11 @@ func CreateBackup(w io.Writer) (*BackupManifest, error) { } sum := sha256.Sum256(dbBytes) - db := repository.GetDB() - recordCount := 0 - if db != nil { - db.RLock() - recordCount = len(db.Users) + len(db.Clusters) + len(db.Hosts) + len(db.Storages) + - len(db.Deployments) + len(db.Collectors) + len(db.LogSources) + - len(db.AlertRules) + len(db.NotifyChannels) + len(db.LogPolicyGroups) + len(db.AuditLogs) - db.RUnlock() - } + // 记录数从**备份出来的字节**里数,不用 repository.GetDB(): + // CLI 模式(lokistack backup,跑在 CronJob 里)根本没初始化 DB, + // 用 GetDB() 会得到 nil,manifest 里的 recordCount 恒为 0, + // 恢复时就看不出"这份备份里有没有东西"。 + recordCount := countRecords(dbBytes) manifest := &BackupManifest{ Version: "2.0.0", @@ -249,6 +245,22 @@ func ValidateAndStageBackup(r io.Reader) (*RestoreResult, error) { return res, nil } +// countRecords 从数据库 JSON 字节里统计各集合的记录总数。 +func countRecords(dbBytes []byte) int { + var raw map[string]json.RawMessage + if err := json.Unmarshal(dbBytes, &raw); err != nil { + return 0 + } + total := 0 + for _, v := range raw { + var arr []json.RawMessage + if err := json.Unmarshal(v, &arr); err == nil { + total += len(arr) + } + } + return total +} + // encryptionKeyFingerprint 返回当前 ENCRYPTION_KEY 的指纹(sha256 前 16 位),不泄露密钥。 func encryptionKeyFingerprint() string { key := os.Getenv("ENCRYPTION_KEY") diff --git a/backend/services/selfcheck.go b/backend/services/selfcheck.go index 2ae990e..374ab58 100644 --- a/backend/services/selfcheck.go +++ b/backend/services/selfcheck.go @@ -1,6 +1,7 @@ package services import ( + "encoding/json" "fmt" "net/http" "os" @@ -50,8 +51,25 @@ func SelfCheck() SelfCheckResult { db := repository.GetDB() // 1) 数据库 + // + // 注意:CLI 模式(lokistack self-check,CronJob/运维脚本用)不会 init DB, + // repository.GetDB() 返回 nil。此时不能报"未初始化"——那是误报, + // 改为直接解析数据文件统计,让 CLI 与 API 结论一致。 if db == nil { - add(CheckItem{Name: "数据库", Status: "error", Message: "未初始化"}) + dbPath := databasePath() + fi, err := os.Stat(dbPath) + if err != nil { + add(CheckItem{Name: "数据库", Status: "error", Message: "数据文件读不到", Detail: err.Error()}) + } else { + stats := fileDBStats(dbPath) + status := "ok" + if fi.Size() > 16<<20 { + status = "warn" + } + add(CheckItem{Name: "数据库", Status: status, + Message: fmt.Sprintf("%.1f KB(CLI 模式读取)", float64(fi.Size())/1024), + Detail: stats}) + } } else { dbPath := databasePath() fi, err := os.Stat(dbPath) @@ -223,6 +241,28 @@ func probeDeploymentReady(d *models.LokiDeployment) (bool, error) { return resp.StatusCode == 200 && strings.Contains(strings.ToLower(string(buf[:n])), "ready"), nil } +// fileDBStats 直接解析数据文件给出各集合记录数(CLI 模式用)。 +func fileDBStats(path string) string { + raw, err := os.ReadFile(path) + if err != nil { + return "" + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return "数据文件不是合法 JSON" + } + count := func(key string) int { + var arr []json.RawMessage + if v, ok := m[key]; ok && json.Unmarshal(v, &arr) == nil { + return len(arr) + } + return 0 + } + return fmt.Sprintf("集群 %d / 主机 %d / 存储 %d / 部署 %d / 采集器 %d / 审计 %d", + count("clusters"), count("hosts"), count("storages"), + count("deployments"), count("collectors"), count("auditLogs")) +} + // diskUsage 返回剩余与总空间(字节) func diskUsage(path string) (free, total uint64, err error) { var st syscall.Statfs_t