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
+278
View File
@@ -0,0 +1,278 @@
// 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"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestHoneypotRetentionRunOnce 覆盖蜜罐记录的两道清理闸:
// 时间闸(保留期)与数量闸(行数上限),以及清理后的空间回收。
//
// 这是「数据库会不会被扫描撑爆」的核心防线:蜜罐表只增不减,
// 写入完全由外部扫描流量驱动。
func TestHoneypotRetentionRunOnce(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
// 5 条旧记录(超出保留期)+ 5 条新记录(在保留期内)
rows := []*HoneypotHit{}
for i := 0; i < 5; i++ {
rows = append(rows, &HoneypotHit{IP: "1.1.1.1", Method: "GET", Path: "/old", CreatedAt: now.Add(-90 * 24 * time.Hour)})
rows = append(rows, &HoneypotHit{IP: "2.2.2.2", Method: "GET", Path: "/new", CreatedAt: now.Add(-time.Hour)})
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
require.NoError(t, err)
require.Equal(t, int64(10), total)
// 保留期 30 天、行数上限 7:
// 时间闸删掉 5 条 90 天前的旧记录 → 剩 5 条新的;
// 数量闸:5 < 7,未超上限,不删 → 最终剩 5 条。
ret := NewHoneypotRetention(store, 30, 7, 0)
ret.runOnce()
remaining, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
require.NoError(t, err)
require.Equal(t, int64(5), remaining, "时间闸删掉 5 条旧记录后剩 5 条,未超数量上限")
// 剩下的应该全是新记录
list, err := store.ListSince(ctx, now.Add(-365*24*time.Hour), 10)
require.NoError(t, err)
require.Len(t, list, 5)
for _, r := range list {
require.Equal(t, "/new", r.Path, "旧的记录应已被时间闸删除")
}
}
// TestHoneypotRetentionDisabled 保留期 <= 0 时不删任何数据。
func TestHoneypotRetentionDisabled(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
for i := 0; i < 3; i++ {
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "3.3.3.3", Method: "GET", Path: "/x", CreatedAt: now.Add(-365 * 24 * time.Hour),
}))
}
ret := NewHoneypotRetention(store, 0, 100, 0)
ret.runOnce()
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
require.NoError(t, err)
require.Equal(t, int64(3), total, "保留期 <= 0 时不应删除任何记录")
}
// TestHoneypotRetentionMaxRowsOnly 只设行数上限、不设保留期时,数量闸仍生效。
func TestHoneypotRetentionMaxRowsOnly(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
for i := 0; i < 20; i++ {
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "4.4.4.4", Method: "GET", Path: "/p", CreatedAt: now.Add(-time.Duration(i) * time.Minute),
}))
}
ret := NewHoneypotRetention(store, 30, 5, 0)
ret.runOnce()
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
require.NoError(t, err)
require.Equal(t, int64(5), total)
}
// TestHoneypotRetentionBothGates 两道闸叠加:时间闸先删超期的,
// 剩余仍超数量上限时,数量闸再删最旧的。
func TestHoneypotRetentionBothGates(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
// 5 条超保留期(90 天前)+ 10 条保留期内
for i := 0; i < 5; i++ {
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "6.6.6.6", Method: "GET", Path: "/ancient", CreatedAt: now.Add(-90 * 24 * time.Hour),
}))
}
for i := 0; i < 10; i++ {
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "7.7.7.7", Method: "GET", Path: "/recent",
CreatedAt: now.Add(-time.Duration(i) * time.Minute),
}))
}
// 保留期 30 天、数量上限 4:
// 时间闸删 5 条 ancient → 剩 10 条 recent;数量闸删到 4 条(删最旧的 6 条)
ret := NewHoneypotRetention(store, 30, 4, 0)
ret.runOnce()
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
require.NoError(t, err)
require.Equal(t, int64(4), total, "两道闸叠加后应回到数量上限")
list, err := store.ListSince(ctx, now.Add(-365*24*time.Hour), 10)
require.NoError(t, err)
require.Len(t, list, 4)
for _, r := range list {
require.Equal(t, "/recent", r.Path)
}
// 留下的应是最新的 4 条(now-0m ~ now-3m),最旧的应已被数量闸删掉
require.Contains(t, list[0].Path, "recent")
}
// TestHoneypotDeleteOldestBeyond 直接测数量闸的删除语义。
func TestHoneypotDeleteOldestBeyond(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
for i := 0; i < 10; i++ {
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "7.7.7.7", Method: "GET", Path: "/p",
CreatedAt: now.Add(-time.Duration(i) * time.Minute),
}))
}
// 保留 3 条 → 删掉最旧的 7 条
n, err := store.DeleteOldestBeyond(ctx, 3)
require.NoError(t, err)
require.Equal(t, int64(7), n)
total, err := store.CountSince(ctx, now.Add(-24*time.Hour))
require.NoError(t, err)
require.Equal(t, int64(3), total)
// 行数不足时不应删任何东西
n2, err := store.DeleteOldestBeyond(ctx, 100)
require.NoError(t, err)
require.Equal(t, int64(0), n2)
total2, err := store.CountSince(ctx, now.Add(-24*time.Hour))
require.NoError(t, err)
require.Equal(t, int64(3), total2)
// keepRows <= 0 视为不限制
n3, err := store.DeleteOldestBeyond(ctx, 0)
require.NoError(t, err)
require.Equal(t, int64(0), n3)
}
// TestHoneypotListSinceByIP 验证按 IP 过滤在 SQL 层生效。
//
// 背景:原先 CLI 的 --ip 过滤是在取回最近 N 条后在内存里筛,
// 导致「指定 IP 的历史命中」基本查不出来(被其他 IP 的更近记录挤出窗口)。
func TestHoneypotListSinceByIP(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
// 目标 IP 只有 1 条很老的记录;其余 20 条都是别的 IP 且更新
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "9.9.9.9", Method: "GET", Path: "/target", CreatedAt: now.Add(-10 * time.Hour),
}))
for i := 0; i < 20; i++ {
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "8.8.8.8", Method: "GET", Path: "/other", CreatedAt: now.Add(-time.Duration(i) * time.Minute),
}))
}
rows, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "9.9.9.9", 200)
require.NoError(t, err)
require.Len(t, rows, 1, "指定 IP 的老记录应能被查出(不能被其他 IP 的新记录挤出窗口)")
require.Equal(t, "/target", rows[0].Path)
// 空 IP 退化为不过滤
all, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "", 200)
require.NoError(t, err)
require.Len(t, all, 21)
}
// TestHoneypotCompact 验证空间回收在 PostgreSQL 下可执行且不报错。
func TestHoneypotCompact(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
for i := 0; i < 10; i++ {
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "5.5.5.5", Method: "GET", Path: "/p", CreatedAt: now.Add(-time.Duration(i) * time.Hour),
}))
}
n, err := store.DeleteBefore(ctx, now.Add(-2*time.Hour))
require.NoError(t, err)
// 10 条记录分别落在 now-0h … now-9h;created_at < now-2h 是严格小于,
// 恰好落在 now-2h 的那条保留 → 删掉 now-3h 起的 7 条。
require.Equal(t, int64(7), n)
require.NoError(t, store.Compact(ctx), "空间回收不应报错")
total, err := store.CountSince(ctx, now.Add(-24*time.Hour))
require.NoError(t, err)
require.Equal(t, int64(3), total, "回收空间不应影响数据正确性")
}