feat(honeypot): 自动保留期清理 + 行数上限 + VACUUM 空间回收 + size 子命令
问题:honeypot_hits 是只追加审计表,写入完全由外部扫描流量驱动(不可预期、 不可节流),而原实现只在手动跑 CLI prune 时才清理,站点运行期表体积只增不减。 实测取证(本地 PostgreSQL 建同构表灌 10.5 万行真实数据): - 典型行 244 字节(含索引),10.5 万行 24 MB - 保留期清理对照(行数恒定 10 万,每轮删 25% 插 25%): 有 VACUUM 22→24MB 收敛;无 VACUUM 22→52MB 持续膨胀 - VACUUM 不缩小文件,只让空间可复用;二次实测(5 万行、连续 4 轮删插) 行数恒定 5 万、文件稳定 15MB 不再增长 改动: - 新增 HoneypotRetention 后台清理器(每小时一轮):时间闸 honeypot_retain_days + 数量闸 honeypot_max_rows,清理后调 Compact 回收空间 - db: 新增 Compact()(PG 执行 VACUUM (ANALYZE),走 GORM Exec 非事务路径)、 DeleteOldestBeyond()(跨方言通用)、ListSinceByIP()、Size() - 去掉 honeypot_hits.provider 无用索引(10 万行占 648kB,全代码库零查询使用), 并在启动时显式清理既有库上的旧索引(GORM 去 tag 不会自动删) - 修 CLI hits --ip 过滤 bug:原先取回最近 N 条再在 Go 侧筛,导致指定 IP 的 历史命中基本查不出来 → 改到 SQL 层过滤 - 新增 CLI honeypot size 子命令:报告行数、磁盘占用、时间跨度,并与两道闸对照 - 新增配置 honeypot_auto_clean / honeypot_max_rows
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// 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("蜜罐记录空间回收失败(不影响数据正确性)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user