From b2c82da37706b93fd4067fccae319c30ed882ea7 Mon Sep 17 00:00:00 2001 From: opsx Date: Wed, 28 Oct 2026 21:40:24 +0000 Subject: [PATCH] =?UTF-8?q?fix(audit):=20inferAuditFields=20=E4=B8=8B?= =?UTF-8?q?=E6=A0=87=E8=B6=8A=E7=95=8C=E5=AF=BC=E8=87=B4=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=BA=A7=20panic=EF=BC=884=20=E6=AE=B5=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=85=A8=E9=83=A8=20500=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 现象(线上实际日志)=== http: panic serving 172.19.16.138:35256: runtime error: index out of range [4] with length 4 lokistack/middleware.inferAuditFields(...) lokistack/middleware/audit.go:179 === 根因 === segments := strings.Split(strings.Trim(path, "/"), "/") if len(segments) >= 4 { resource = segments[3] resourceID = segments[4] // ← 4 段时越界 } /api/v1/hosts/2 正好拆成 4 段 [api v1 hosts 2],取 segments[4] 直接 panic。 影响面比想象大:**所有 4 段路径的写请求**(PUT/DELETE /hosts/:id、 /storage/:id、/clusters/:id 等)都 500,且中间件 panic 后审计也写不进去 —— 等于审计功能在最常用的更新/删除接口上完全失效。 === 修法 === 每个下标单独判断长度: if len(segments) >= 3 { module = segments[2] } if len(segments) >= 4 { resource = segments[3] } if len(segments) >= 5 { resourceID = segments[4] } === 回归测试(middleware/audit_test.go)=== TestInferAuditFieldsNoPanic : 0/1/2/3/4/5 段的 12 种路径 × 3 种方法, 全部不得 panic(原 bug 的 4 段路径在列) TestInferAuditFieldsFourSegments : 4 段时 module/resource 正确、resourceID 为空 TestInferAuditFieldsFiveSegments : 5 段时能解出 resourceID TestInferAuditFieldsSemanticActions: deploy/stop/install/uninstall/reset-password/ validate/test 语义动作不被位置解析覆盖 TestShouldAudit : 只读接口跳过、部署安装类必须记 === 顺带修:CLI 模式下的两处误报 === 1) backup 的 manifest.recordCount 恒为 0 原因:CLI(CronJob 里跑 lokistack backup)没 init DB,GetDB() 返回 nil。 改为 countRecords() 直接从备份出的字节数集合。 2) self-check 报「数据库: 未初始化」 同上原因,误报会让巡检脚本以为库坏了。 改为 CLI 模式直接解析数据文件统计(fileDBStats)。 === 实测(修复后)=== 修复前:POST /api/v1/hosts/2/validate → panic,无审计记录 修复后:POST /api/v1/hosts/2/validate → 200 audit-logs total=5,含 [hosts/validate] admin POST /api/v1/hosts/2/validate -> 200 [auth/login] POST /api/v1/auth/login -> 200 panic serving 计数 = 0 用户数据完好(initial-admin-password.txt 不存在,说明不是首次部署) 镜像:lghost/lokistack:latest + v0.1 = 2493a288758d --- backend/middleware/audit.go | 8 +++ backend/middleware/audit_test.go | 109 +++++++++++++++++++++++++++++++ backend/services/backup.go | 30 ++++++--- backend/services/selfcheck.go | 42 +++++++++++- 4 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 backend/middleware/audit_test.go 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