// 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、绝不阻断主流程:蜜罐是安全增强功能, // 不能因为它把站点搞挂。 // // CredPurgeable 是「凭据文件按月清理」的能力(由 honeypot.CredStore 实现)。 // // 用本地小接口而不是直接 import honeypot 包:存储层不该反向依赖安全层, // 也能让清理器在测试里用假实现。 type CredPurgeable interface { // PurgeBefore 删除早于 before 的整天文件,返回删除的文件数。 PurgeBefore(before time.Time) (int, error) } type HoneypotRetention struct { store HoneypotStore creds CredPurgeable retainDays int maxRows int interval time.Duration } // NewHoneypotRetention 构造保留期清理器。 // // retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限; // interval <= 0 时使用默认 1 小时;creds 为 nil 表示不清理凭据文件。 func NewHoneypotRetention(store HoneypotStore, creds CredPurgeable, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention { if interval <= 0 { interval = time.Hour } return &HoneypotRetention{ store: store, creds: creds, 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 } // 凭据文件按同一个保留期清理:明文账号密码不该比元数据活得更久。 // 独立于 DB 的删除量 —— 即使这批数据库记录本来就已达标,文件仍可能过期。 if r.creds != nil { if n, err := r.creds.PurgeBefore(before); err != nil { logrus.WithError(err).Warn("蜜罐凭据文件清理失败(不影响数据库记录)") } else if n > 0 { logrus.WithFields(logrus.Fields{"files": n, "days": r.retainDays}). Info("已清理过期的蜜罐凭据文件") } } 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("蜜罐记录空间回收失败(不影响数据正确性)") } }