Files
tamabox/internal/db/honeypot_retention.go
T
tamakyi d300167a30 fix(conf): 配置键名容错 + 未知键告警 + 蜜罐保留期缺省值修正
现场故障:配置写 `honeypot_retention_days <= 30`,日志却报
「蜜罐自动清理未启用:honeypot_retention_days <= 0」。

根因(实测 ini v1.67.0 行为确认):
1. 键名多一个 t(retention vs retain)→ ini 对未知键**静默忽略**,保留期成 0;
2. `<= 30` 这种写法让 ini 把键名解析成 "honeypot_retention_days <"
   (带尾随比较符),更加匹配不上;
3. 警告文案里写的也是错键名,等于在误导运维往错的键上配。

修复(三层):
- 键名归一化(internal/conf/keynorm.go):修剪首尾空白、剥掉尾随 `<` `>`
  `=` 等比较符残留,并按别名表纠正常见误拼。归一化结果必须命中该段
  合法键名白名单才会被采用 —— 不认识的键不猜,交给未知键告警。
- 未知键告警(internal/conf/unknown.go):启动时逐段比对键名,无法识别的
  键 WARN 出来并给出最接近的合法键名建议。合法键名由反射从结构体 ini tag
  得出(单一事实来源,新增配置项自动纳入,不会脱节)。
- 缺省值语义修正(internal/conf/honeypot_defaults.go):
  * 保留期缺省 30 天(原先零值 0 = 永不清理);显式 <= 0 也回退 30。
    要关闭自动清理请用 honeypot_auto_clean = false。
  * honeypot_auto_clean 缺省 true。原先靠 bool 零值,ini 的 bool 解析是
    「有键才赋值」,未配置与显式关闭无法区分 → 老配置升级上来自动清理
    实际是关着的。现改用 HasKey 判定。
  * honeypot_max_rows 缺省 200000;显式 0 = 不限(会告警提醒只增不减)。
  * honeypot_threshold 缺省 1。

实测 ini 行为(探针程序,已删):
- 键缺失或值非法时 MapTo **不覆盖**预置默认值 → 可安全预置缺省值;
- 键名大小写敏感(Honeypot_Auto_Clean 不生效);
- HasKey 能区分「缺失」与「显式 0」;
- 段不存在时 MapTo 返回 nil 且不修改目标。

顺带清理 app.sample.ini 里的两条死配置(app.title / server.xsrf_expire):
代码中从未读取(站点标题走 i18n),留着会让新检测对官方模板误报。

验证:
- 用现场原始配置端到端复现 → 归一化后保留期 30 天、自动清理开启、上限 20 万,
  日志明确说明「配置键名已自动纠正」;
- 误拼键(honeypot_autoclean / honeypot_max_row)自动纠正;未知键(totally_bogus)
  WARN 告警;
- 显式 honeypot_auto_clean = false 被尊重为关闭;完全未配置时缺省开启;
- 新增 TestSampleIniHasNoUnknownKeys 回归检测:官方样例配置零误报;
- go build ./... + go test -timeout 600s ./... 全绿,go vet 无输出。
2026-10-21 13:41:19 +08:00

143 lines
4.7 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.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"github.com/sirupsen/logrus"
)
// HoneypotRetention 蜜罐命中记录的保留期清理器。
//
// 为什么必须有它:蜜罐表是「只追加」的审计表,写入完全由外部扫描流量驱动
// (无法预期、无法节流、攻击者可以无限打)。若只靠人工跑 CLI prune,
// 表体积只增不减 —— 这是「数据库被扫描撑爆」的真实路径。
//
// 清理策略(每轮两道闸):
// 1. 时间闸:删除 created_at 早于保留期起点的记录;
// 2. 数量闸:若行数仍超过上限,删除最旧的记录直到回到上限内。
// 保留期是时间维度上界,但攻击者可以用海量源 IP 在保留期内把表撑爆;
// 数量闸提供第二重保险。
//
// 之后若确实删掉了行,再做一次空间回收(PostgreSQL 需要 VACUUM,
// 否则 DELETE 只标记死元组,表文件不会变小)。
//
// 设计取舍:
// - 频率固定为每小时一轮,不做成可配置:清理本身极轻(一次 DELETE + 一次
// VACUUM),而「忘记开」的后果是磁盘单调增长。可配置的频率只会让更多人
// 忘了配。
// - 保留期由配置给出(honeypot_retain_days),<= 0 视为「不自动清理」,
// 此时只记一条告警日志,不删任何数据。
// - 清理失败只记日志,绝不 panic、绝不阻断主流程:蜜罐是安全增强功能,
// 不能因为它把站点搞挂。
type HoneypotRetention struct {
store HoneypotStore
retainDays int
maxRows int
interval time.Duration
}
// NewHoneypotRetention 构造保留期清理器。
//
// retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限;
// interval <= 0 时使用默认 1 小时。
func NewHoneypotRetention(store HoneypotStore, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
if interval <= 0 {
interval = time.Hour
}
return &HoneypotRetention{
store: store,
retainDays: retainDays,
maxRows: maxRows,
interval: interval,
}
}
// Start 启动后台清理循环,直到 stop 被关闭。
//
// 立即执行一轮(而不是等满一个周期后才开始),避免重启后旧数据一直滞留。
func (r *HoneypotRetention) Start(stop <-chan struct{}) {
if r == nil || r.store == nil {
return
}
if r.retainDays <= 0 {
// ⚠️ 键名是 honeypot_retain_days(没有 tion)。这里曾经写成
// honeypot_retention_days,把运维引到了错误的键名上 —— 现场确实踩了这个坑。
// 正常路径下 conf.sanitizeHoneypot 已保证保留期 > 0(缺省 30 天),
// 能走到这里说明配置被显式写成了 <= 0。
logrus.Warn("蜜罐自动清理未启用:honeypot_retain_days <= 0,honeypot_hits 表将只增不减(可手动执行 TamaBox honeypot prune)")
return
}
go func() {
r.runOnce()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
r.runOnce()
}
}
}()
}
// RunOnceForTest 导出一轮清理,供端到端体积验证脚本调用。
// 仅测试/诊断用途,生产路径走 Start。
func (r *HoneypotRetention) RunOnceForTest() {
r.runOnce()
}
// runOnce 执行一轮清理。
func (r *HoneypotRetention) runOnce() {
// 保留期 <= 0 表示「不自动清理」。必须在这里再挡一次:
// retainDays=0 时 before = now,会把所有 created_at < now 的记录全删光 ——
// 等于「保留期 0 天」被误解释成「只留此刻」。
if r.retainDays <= 0 {
return
}
before := time.Now().AddDate(0, 0, -r.retainDays)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
total := int64(0)
// 闸一:时间。按保留期删掉过期记录。
n, err := r.store.DeleteBefore(ctx, before)
if err != nil {
logrus.WithError(err).Warn("蜜罐记录保留期清理失败")
return
}
total += n
// 闸二:数量。保留期内的行数仍超上限时,删掉最旧的。
if r.maxRows > 0 {
m, err := r.store.DeleteOldestBeyond(ctx, r.maxRows)
if err != nil {
logrus.WithError(err).Warn("蜜罐记录数量上限清理失败")
return
}
total += m
}
if total == 0 {
return
}
logrus.WithFields(logrus.Fields{
"deleted": total,
"days": r.retainDays,
"maxRows": r.maxRows,
}).Info("蜜罐记录清理完成")
// 只有真删掉了行才回收空间:VACUUM 不是免费的,别为 0 行去扫全表。
if err := r.store.Compact(ctx); err != nil {
logrus.WithError(err).Warn("蜜罐记录空间回收失败(不影响数据正确性)")
}
}