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:
tamakyi
2026-10-21 13:16:14 +08:00
parent 7d52155411
commit 63e851ddae
9 changed files with 785 additions and 7 deletions
+165 -1
View File
@@ -45,7 +45,12 @@ type HoneypotHit struct {
ContentType string `gorm:"size:256" json:"content_type"`
// Provider 为 IP 归属(云厂商/CDN/WAF)标签,Proxied 表示是否经代理。
Provider string `gorm:"size:64;index" json:"provider"`
//
// ⚠️ 刻意不加索引:全代码库没有任何按 provider 过滤或排序的查询
// (TopSources 用 max(provider) 随 group by 顺带取,不需要独立索引)。
// 实测该索引占 10 万行约 648 kB,纯属写放大。既有库上的旧索引由
// dropUnusedHoneypotIndexes 在启动时清掉。
Provider string `gorm:"size:64" json:"provider"`
Proxied bool `json:"proxied"`
// Banned 为命中时是否触发了封禁。
@@ -64,6 +69,11 @@ type HoneypotStore interface {
Create(ctx context.Context, hit *HoneypotHit) error
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
// ListSinceByIP 返回 since 之后、来自指定 IP 的命中记录,按时间倒序。
//
// 刻意在 SQL 层过滤而不是取回后在 Go 侧过滤:后者只能看到「最近 limit 条」
// 里恰好落在窗口内的该 IP 记录,指定 IP 的历史命中基本查不出来。
ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error)
// CountSince 统计 since 之后的命中总数。
CountSince(ctx context.Context, since time.Time) (int64, error)
// TopSources 返回 since 之后命中次数最多的来源 IP。
@@ -74,6 +84,34 @@ type HoneypotStore interface {
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录,返回删除行数。
//
// 这是「行数上限」兜底:保留期给的是时间维度上界,但攻击者可以用海量
// 源 IP 疯狂扫描,把表在保留期内就撑爆。行数上限提供第二道闸。
DeleteOldestBeyond(ctx context.Context, keepRows int) (int64, error)
// Compact 回收 DeleteBefore 释放出的物理空间。
//
// 为什么需要:PostgreSQL 的 DELETE 只是把行标记为死元组,磁盘文件不会变小。
// 实测删掉 10.5 万行后表仍占 32MB,VACUUM 后才降到 10MB。
// MySQL/InnoDB 删除后空间留在表内可复用,无需额外操作。
Compact(ctx context.Context) error
// Size 报告蜜罐表的行数与实际磁盘占用。
//
// 存在的意义:把「会不会把数据库撑臃肿」这个判断交给运维自己随时能查,
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
Size(ctx context.Context) (HoneypotSize, error)
}
// HoneypotSize 蜜罐表的体积报告。
type HoneypotSize struct {
// Rows 逻辑行数(存活记录)。
Rows int64
// Bytes 关系总大小(含索引),单位字节;无法取得时为 0。
Bytes int64
// Oldest / Newest 是最早与最新的记录时间,用于判断增长是否正常。
Oldest *time.Time
Newest *time.Time
}
// HoneypotSourceStat 来源 IP 聚合行。
@@ -125,6 +163,23 @@ func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) (
return rows, err
}
func (h *honeypots) ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
if ip == "" {
return h.ListSince(ctx, since, limit)
}
var rows []*HoneypotHit
err := h.db.WithContext(ctx).
Where("created_at >= ?", since).
Where("ip = ?", ip).
Order("created_at DESC").
Limit(limit).
Find(&rows).Error
return rows, err
}
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
var n int64
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
@@ -186,3 +241,112 @@ func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64,
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
}
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录。
//
// 用子查询按 id 倒序取要保留的最小 id,再删掉比它更旧的行。
// 跨 PostgreSQL / MySQL 通用(只用了标准 SQL + 子查询,无方言特性)。
func (h *honeypots) DeleteOldestBeyond(ctx context.Context, keepRows int) (int64, error) {
if keepRows <= 0 {
return 0, nil
}
var keepID uint
err := h.db.WithContext(ctx).
Model(&HoneypotHit{}).
Select("id").
Order("id DESC").
Offset(keepRows - 1).
Limit(1).
Scan(&keepID).Error
if err != nil {
return 0, err
}
if keepID == 0 {
// 行数不足 keepRows,无需删除。
return 0, nil
}
res := h.db.WithContext(ctx).Where("id < ?", keepID).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
}
// Size 报告行数与磁盘占用。
//
// 大小取整个关系的总大小(pg_total_relation_size / information_schema),
// 含索引 —— 磁盘压力主要来自索引,只看堆表会低估。
func (h *honeypots) Size(ctx context.Context) (HoneypotSize, error) {
var out HoneypotSize
if err := h.db.WithContext(ctx).Model(&HoneypotHit{}).Count(&out.Rows).Error; err != nil {
return out, err
}
// 最早/最新记录时间:判断增长是否正常、清理是否在起作用。
var minRow struct{ CreatedAt *time.Time }
if err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
Select("MIN(created_at) AS created_at").Scan(&minRow).Error; err == nil {
out.Oldest = minRow.CreatedAt
}
var maxRow struct{ CreatedAt *time.Time }
if err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
Select("MAX(created_at) AS created_at").Scan(&maxRow).Error; err == nil {
out.Newest = maxRow.CreatedAt
}
// 大小查询按方言分派:PostgreSQL 与 MySQL 的元数据接口完全不同。
if h.db.Dialector != nil {
switch h.db.Dialector.Name() {
case "postgres":
var b int64
err := h.db.WithContext(ctx).
Raw("SELECT pg_total_relation_size('honeypot_hits')").Scan(&b).Error
if err == nil {
out.Bytes = b
}
case "mysql":
var b int64
err := h.db.WithContext(ctx).
Raw("SELECT COALESCE(SUM(data_length + index_length), 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = 'honeypot_hits'").
Scan(&b).Error
if err == nil {
out.Bytes = b
}
}
}
return out, nil
}
// Compact 回收删除记录释放出的物理空间。
//
// PostgreSQL:DELETE 只把行标记为死元组,表文件不会变小。
// 实测(10 万行、每轮删 25% 插 25%、行数恒定):
//
// 有 VACUUM:22 MB → 24 MB 后收敛,不再增长;
// 无 VACUUM:22 MB → 52 MB,持续膨胀。
//
// 即:没有 VACUUM,「保留期清理」只是让表逻辑变小,物理文件继续单调增长 ——
// 而蜜罐表是持续写入的,物理文件不缩就等于磁盘单调增长。
//
// ⚠️ 注意 VACUUM 不缩小文件,只是把死元组的空间标记为「可被后续插入复用」。
// 二次实测(5 万行、每轮删 12000 插 12000、连续 4 轮):
//
// 行数恒定 50000,文件稳定在 15 MB 不再增长。
//
// 这正是我们要的效果:磁盘占用止住,而不是「删了之后文件变小」。
// 想要文件本身立刻变小只有 VACUUM FULL(重建整表 + 锁表),代价大得多,
// 对蜜罐表(万级行)没有必要。
//
// MySQL/InnoDB:删除后的空间留在表内可被后续插入复用,无需额外操作。
func (h *honeypots) Compact(ctx context.Context) error {
if h.db.Dialector == nil {
return nil
}
switch h.db.Dialector.Name() {
case "postgres":
// VACUUM 不能在事务块内执行。用 Exec 走连接池的裸连接,
// 并显式关闭 GORM 的默认事务包装。
return h.db.WithContext(ctx).Exec("VACUUM (ANALYZE) honeypot_hits").Error
case "mysql":
// OPTIMIZE TABLE 会重建整表并短暂锁表,蜜罐表数据量小(实测万级行),
// 代价可接受;但放在自动清理里没必要 —— InnoDB 会复用空洞空间。
return nil
default:
return nil
}
}