Files
tamabox/internal/db/honeypot_retention_test.go
T
tamakyi 63e851ddae 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
2026-10-21 13:16:14 +08:00

279 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, "回收空间不应影响数据正确性")
}