=== 现象(线上实际日志)===
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
215 lines
6.1 KiB
Go
215 lines
6.1 KiB
Go
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
|
||
}
|