Files
LokiStack/backend/middleware/audit.go
T
opsx b2c82da377 fix(audit): inferAuditFields 下标越界导致进程级 panic(4 段路径全部 500)
=== 现象(线上实际日志)===
    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
2026-10-28 21:40:24 +00:00

215 lines
6.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 middleware
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"lokistack/models"
"lokistack/services"
)
// AuditInfo 由 controller 主动补充的业务语义。
//
// 中间件已经能自动记录 method/path/status/耗时/IP/用户,
// 但「改了哪个部署」「保留天数 7 → 3」这类信息只有 handler 自己知道,
// 所以提供 SetAudit 让 handler 补一刀,不补也能记全(只是 Detail 为空)。
type AuditInfo struct {
Module string
Action string
Resource string
ResourceID string
Detail string
Before string
After string
// Skip 置 true 表示这条请求不记(例如纯查询用了 POST 的场景)
Skip bool
}
const auditInfoKey = "audit.info"
// SetAudit 由 controller 调用,补充审计语义。
func SetAudit(c *gin.Context, info AuditInfo) {
c.Set(auditInfoKey, info)
}
// auditSkipPaths 这些路径不记审计:
// - 健康检查 / metrics:高频噪音
// - 日志查询:GET 本来就不记,但 /logs/query 之类用 POST 的也不记(只读)
var auditSkipPrefixes = []string{
"/api/v1/health",
"/api/v1/metrics",
"/api/v1/logs/query",
"/api/v1/logs/stats",
"/api/v1/logs/histogram",
"/api/v1/logs/labels",
"/api/v1/logs/label",
"/api/v1/logs/context",
"/api/v1/logs/volume",
"/api/v1/logs/my-scope",
"/api/v1/deployments/", // 例外:其中 /deploy /stop /restart 要记,见下方白名单
}
// auditAlwaysRecord 匹配到的路径即使在上面的 skip 前缀里也要记。
var auditAlwaysRecordSuffixes = []string{
"/deploy", "/stop", "/restart", "/install", "/uninstall", "/test", "/validate",
"/preview", "/rollback", "/reset-password", "/backup", "/restore",
}
// AuditLog 审计中间件:自动记录所有写操作(POST / PUT / PATCH / DELETE)。
//
// 为什么用中间件而不是每个 controller 各写一段:
// - LokiStack 有 60+ 个写接口,逐个埋点必然漏,漏掉的那个恰好最危险(删集群、改存储)
// - 中间件能拿到统一的 status / 耗时 / IP / UA
// - handler 需要更细的语义时再 SetAudit 补,不补也有基础记录
//
// 不含请求体(读 body 会影响后续 binding),敏感字段由 handler 通过 SetAudit
// 传入已脱敏的 Before/After。
func AuditLog() gin.HandlerFunc {
return func(c *gin.Context) {
method := c.Request.Method
// 只读请求不记(GET/HEAD/OPTIONS)
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions {
c.Next()
return
}
path := c.Request.URL.Path
if !shouldAudit(path) {
c.Next()
return
}
start := time.Now()
c.Next()
elapsed := time.Since(start).Milliseconds()
info, _ := c.Get(auditInfoKey)
ai, _ := info.(AuditInfo)
if ai.Skip {
return
}
status := c.Writer.Status()
userID, _ := c.Get("userId")
username, _ := c.Get("username")
role, _ := c.Get("role")
entry := models.AuditLog{
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Method: method,
Path: path,
Status: status,
DurationMs: elapsed,
Detail: ai.Detail,
Before: ai.Before,
After: ai.After,
CreatedAt: time.Now(),
}
if v, ok := userID.(uint); ok {
entry.UserID = v
}
if v, ok := username.(string); ok {
entry.Username = v
}
if v, ok := role.(string); ok {
entry.Role = v
}
// 失败时把错误信息带上(gin 的 Error 里有 handler 写入的错误)
if status >= 400 {
if len(c.Errors) > 0 {
entry.Error = c.Errors.String()
}
}
// handler 没补语义时按路径推断,保证「谁删了什么」至少能看出模块
module, action, resource, resourceID := inferAuditFields(method, path)
if ai.Module != "" {
module = ai.Module
}
if ai.Action != "" {
action = ai.Action
}
if ai.Resource != "" {
resource = ai.Resource
}
if ai.ResourceID != "" {
resourceID = ai.ResourceID
}
entry.Module, entry.Action, entry.Resource, entry.ResourceID = module, action, resource, resourceID
services.RecordAudit(entry)
}
}
func shouldAudit(path string) bool {
for _, suffix := range auditAlwaysRecordSuffixes {
if strings.HasSuffix(path, suffix) {
return true
}
}
for _, p := range auditSkipPrefixes {
if strings.HasPrefix(path, p) {
return false
}
}
return true
}
// inferAuditFields 从 method + path 推断模块与动作(handler 未补充时的兜底)。
func inferAuditFields(method, path string) (module, action, resource, resourceID string) {
action = map[string]string{
http.MethodPost: "create",
http.MethodPut: "update",
http.MethodPatch: "update",
http.MethodDelete: "delete",
}[method]
segments := strings.Split(strings.Trim(path, "/"), "/")
// /api/v1/<module>/<resource>/<id>
//
// ⚠️ 每个下标必须单独判断长度。曾经写成:
// 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]
}
// 语义化覆盖
switch {
case strings.HasSuffix(path, "/deploy"):
action, resource = "deploy", "部署"
case strings.HasSuffix(path, "/stop"):
action, resource = "stop", "部署"
case strings.HasSuffix(path, "/restart"):
action, resource = "restart", "部署"
case strings.HasSuffix(path, "/install"):
action, resource = "install", "采集器"
case strings.HasSuffix(path, "/uninstall"):
action, resource = "uninstall", "采集器"
case strings.HasSuffix(path, "/test"):
action = "test"
case strings.HasSuffix(path, "/validate"):
action = "validate"
case strings.HasSuffix(path, "/reset-password"):
action, resource = "reset-password", "用户"
case strings.HasSuffix(path, "/login"):
module, action, resource = "auth", "login", "登录"
case strings.HasSuffix(path, "/password"):
module, action, resource = "auth", "change-password", "登录"
}
return module, action, resource, resourceID
}