// 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 { logrus.Warn("蜜罐自动清理未启用:honeypot_retention_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("蜜罐记录空间回收失败(不影响数据正确性)") } }