Files
LokiStack/backend/services/selfcheck.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

274 lines
8.8 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 services
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"syscall"
"time"
"lokistack/models"
"lokistack/repository"
)
// CheckItem 单条自检结果
type CheckItem struct {
// Name 检查项名称(中文可读)
Name string `json:"name"`
// Status ok / warn / error
Status string `json:"status"`
// Message 一句话结论
Message string `json:"message,omitempty"`
// Detail 补充信息(数量、路径、错误原文)
Detail string `json:"detail,omitempty"`
}
// SelfCheckResult 一键诊断结果
type SelfCheckResult struct {
CheckedAt time.Time `json:"checkedAt"`
// Overall ok / warn / error —— 前端据此显示整体颜色
Overall string `json:"overall"`
Items []CheckItem `json:"items"`
}
// SelfCheck 平台自检:把「能不能用」的判断集中到一处。
//
// 设计目标:新人接手或故障时,先看这一个接口,不必在 5 个页面之间来回跳。
// 所有检查都必须有超时保护,不能让诊断本身把平台拖慢。
func SelfCheck() SelfCheckResult {
res := SelfCheckResult{CheckedAt: time.Now(), Overall: "ok"}
add := func(item CheckItem) {
res.Items = append(res.Items, item)
if item.Status == "error" {
res.Overall = "error"
} else if item.Status == "warn" && res.Overall == "ok" {
res.Overall = "warn"
}
}
db := repository.GetDB()
// 1) 数据库
//
// 注意:CLI 模式(lokistack self-check,CronJob/运维脚本用)不会 init DB,
// repository.GetDB() 返回 nil。此时不能报"未初始化"——那是误报,
// 改为直接解析数据文件统计,让 CLI 与 API 结论一致。
if db == nil {
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)
if err != nil {
add(CheckItem{Name: "数据库", Status: "error", Message: "数据文件读不到", Detail: err.Error()})
} else {
db.RLock()
counts := fmt.Sprintf("集群 %d / 主机 %d / 存储 %d / 部署 %d / 采集器 %d / 审计 %d",
len(db.Clusters), len(db.Hosts), len(db.Storages), len(db.Deployments), len(db.Collectors), len(db.AuditLogs))
db.RUnlock()
status := "ok"
if fi.Size() > 16<<20 {
status = "warn"
}
add(CheckItem{Name: "数据库", Status: status,
Message: fmt.Sprintf("%.1f KB", float64(fi.Size())/1024), Detail: counts})
}
}
// 2) 加密密钥(丢了所有 kubeconfig/凭证都解不开)
if fp := encryptionKeyFingerprint(); fp == "" {
add(CheckItem{Name: "加密密钥", Status: "error",
Message: "ENCRYPTION_KEY 未配置",
Detail: "kubeconfig 与存储凭证依赖它解密;请确认 lokistack-secrets 中的 ENCRYPTION_KEY 已注入"})
} else {
add(CheckItem{Name: "加密密钥", Status: "ok", Message: "已配置", Detail: "指纹 " + fp})
}
// 3) 数据目录可用空间
if free, total, err := diskUsage(dataDirPath()); err == nil && total > 0 {
pct := float64(free) / float64(total) * 100
status, msg := "ok", fmt.Sprintf("剩余 %.1f GB(%.0f%%)", float64(free)/(1<<30), pct)
if pct < 10 {
status = "error"
} else if pct < 20 {
status = "warn"
}
add(CheckItem{Name: "数据盘", Status: status, Message: msg})
}
if db != nil {
db.RLock()
clusterList := db.Clusters
hostList := db.Hosts
deployList := db.Deployments
collectorList := db.Collectors
auditList := db.AuditLogs
db.RUnlock()
// 4) 集群接入(kubeconfig 能否解密)
if len(clusterList) == 0 {
add(CheckItem{Name: "K8s 集群", Status: "warn", Message: "尚未添加集群"})
} else {
bad := 0
for i := range clusterList {
if _, err := resolveKubeconfig(&clusterList[i]); err != nil {
bad++
}
}
if bad > 0 {
add(CheckItem{Name: "K8s 集群", Status: "error",
Message: fmt.Sprintf("%d/%d 个集群的 kubeconfig 无法解密", bad, len(clusterList)),
Detail: "多半是 ENCRYPTION_KEY 与写入时不一致"})
} else {
add(CheckItem{Name: "K8s 集群", Status: "ok", Message: fmt.Sprintf("%d 个,kubeconfig 均可解密", len(clusterList))})
}
}
// 5) 主机可达性(用最近一次验证结果,不在这里做 SSH 连接,避免诊断变慢)
if len(hostList) > 0 {
errCount := 0
for _, h := range hostList {
if h.Status == "error" {
errCount++
}
}
if errCount > 0 {
add(CheckItem{Name: "主机", Status: "warn",
Message: fmt.Sprintf("%d/%d 台连接异常", errCount, len(hostList)),
Detail: "到「主机管理」点「测试连接」查看具体原因"})
} else {
add(CheckItem{Name: "主机", Status: "ok", Message: fmt.Sprintf("%d 台均正常", len(hostList))})
}
}
// 6) Loki 部署实例(真实探活 /ready)
if len(deployList) == 0 {
add(CheckItem{Name: "Loki 实例", Status: "warn", Message: "尚未部署 Loki"})
} else {
var names []string
bad := 0
for _, d := range deployList {
status, err := probeDeploymentReady(&d)
if err != nil || !status {
bad++
names = append(names, d.Name)
}
}
if bad > 0 {
add(CheckItem{Name: "Loki 实例", Status: "error",
Message: fmt.Sprintf("%d/%d 个实例未就绪", bad, len(deployList)),
Detail: strings.Join(names, ", ")})
} else {
add(CheckItem{Name: "Loki 实例", Status: "ok", Message: fmt.Sprintf("%d 个全部 ready", len(deployList))})
}
}
// 7) 采集器
if len(collectorList) > 0 {
errCount, okCount := 0, 0
for _, c := range collectorList {
switch c.Status {
case "active", "running":
okCount++
case "error":
errCount++
}
}
if errCount > 0 {
add(CheckItem{Name: "采集器", Status: "error",
Message: fmt.Sprintf("%d 个异常", errCount),
Detail: fmt.Sprintf("正常 %d 个;到「采集器」页看具体报错", okCount)})
} else {
add(CheckItem{Name: "采集器", Status: "ok", Message: fmt.Sprintf("%d 个正常", okCount)})
}
}
// 8) 近期失败操作(审计)
recentFail := 0
cutoff := time.Now().Add(-24 * time.Hour)
for _, a := range auditList {
if a.CreatedAt.After(cutoff) && a.Status >= 400 {
recentFail++
}
}
if recentFail > 0 {
add(CheckItem{Name: "近 24h 失败操作", Status: "warn",
Message: fmt.Sprintf("%d 次", recentFail), Detail: "到「审计日志」筛选失败查看详情"})
} else {
add(CheckItem{Name: "近 24h 失败操作", Status: "ok", Message: "无"})
}
}
return res
}
// probeDeploymentReady 探测某个 Loki 部署实例是否 /ready。
//
// 不用 DeploymentReady():那个函数按"部署名 == 数据源名"匹配,
// 而生成的 K8s 资源名固定是 loki,两者对不上会永远返回未就绪(已知问题)。
// 这里直接按部署记录里的 endpoint 探活,并对主机模式修正监听地址:
// 主机 Docker 部署存的是 0.0.0.0:3100,从平台 Pod 侧要用主机真实 IP。
func probeDeploymentReady(d *models.LokiDeployment) (bool, error) {
url := strings.TrimSuffix(strings.TrimSpace(d.Endpoint), "/")
if url == "" {
return false, fmt.Errorf("部署未记录访问地址")
}
if strings.Contains(url, "0.0.0.0") && d.Host != nil && d.Host.HostIP != "" {
url = strings.Replace(url, "0.0.0.0", d.Host.HostIP, 1)
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url + "/ready")
if err != nil {
return false, err
}
defer resp.Body.Close()
buf := make([]byte, 128)
n, _ := resp.Body.Read(buf)
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
if err := syscall.Statfs(path, &st); err != nil {
return 0, 0, err
}
return st.Bavail * uint64(st.Bsize), st.Blocks * uint64(st.Bsize), nil
}