现场故障:配置写 `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 无输出。
143 lines
4.7 KiB
Go
143 lines
4.7 KiB
Go
// 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("蜜罐记录空间回收失败(不影响数据正确性)")
|
||
}
|
||
}
|