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