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:
@@ -80,6 +80,15 @@ enable_honeypot = false ; 开启后,扫描器探测后台路径(
|
|||||||
; 假页面只含 canary 标记与假凭据,绝不含任何真实信息。
|
; 假页面只含 canary 标记与假凭据,绝不含任何真实信息。
|
||||||
honeypot_paths = ; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。
|
honeypot_paths = ; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。
|
||||||
honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期可用 CLI 清理。
|
honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期可用 CLI 清理。
|
||||||
|
; 可用 TamaBox honeypot size 查看行数与磁盘占用。
|
||||||
|
honeypot_auto_clean = true ; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
|
||||||
|
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量
|
||||||
|
; 驱动,不清理的话表体积只增不减。关闭后仍可手动 prune。
|
||||||
|
honeypot_max_rows = 200000 ; 蜜罐命中记录的行数上限(第二道闸)。留空或 0 = 不限。
|
||||||
|
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内
|
||||||
|
; 把表撑爆。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||||
|
; 实测数据:连续删 12000 插 12000,行数恒定 5 万,文件稳定在 15 MB 不再增长
|
||||||
|
; (VACUUM 让空间可复用,不保证文件缩小)。
|
||||||
honeypot_threshold = 1 ; 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):蜜罐路径
|
honeypot_threshold = 1 ; 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):蜜罐路径
|
||||||
; 与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
; 与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
||||||
honeypot_ban_minutes = ; 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes(15)。
|
honeypot_ban_minutes = ; 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes(15)。
|
||||||
|
|||||||
+105
-6
@@ -21,6 +21,7 @@ import (
|
|||||||
//
|
//
|
||||||
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
|
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
|
||||||
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
|
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
|
||||||
|
// TamaBox honeypot size 报告表行数与磁盘占用
|
||||||
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
|
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
|
||||||
//
|
//
|
||||||
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
|
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
|
||||||
@@ -59,6 +60,11 @@ var Honeypot = &cli.Command{
|
|||||||
},
|
},
|
||||||
Action: runHoneypotPrune,
|
Action: runHoneypotPrune,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "size",
|
||||||
|
Usage: "Report honeypot table row count and disk usage",
|
||||||
|
Action: runHoneypotSize,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,13 +107,15 @@ func runHoneypotHits(ctx *cli.Context) error {
|
|||||||
}
|
}
|
||||||
store := db.NewHoneypotStore(database)
|
store := db.NewHoneypotStore(database)
|
||||||
since := time.Now().Add(-dur)
|
since := time.Now().Add(-dur)
|
||||||
|
filterIP := strings.TrimSpace(ctx.String("ip"))
|
||||||
|
showPwd := ctx.Bool("show-password")
|
||||||
|
|
||||||
rows, err := store.ListSince(context.Background(), since, ctx.Int("limit"))
|
// 按 IP 过滤在 SQL 层做:取回最近 N 条再在内存里筛,会导致「指定 IP 的
|
||||||
|
// 历史命中基本查不出来」(它的记录被其他 IP 的更近记录挤出了窗口)。
|
||||||
|
rows, err := store.ListSinceByIP(context.Background(), since, filterIP, ctx.Int("limit"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query honeypot hits")
|
return errors.Wrap(err, "query honeypot hits")
|
||||||
}
|
}
|
||||||
filterIP := strings.TrimSpace(ctx.String("ip"))
|
|
||||||
showPwd := ctx.Bool("show-password")
|
|
||||||
|
|
||||||
fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows))
|
fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows))
|
||||||
fmt.Println(strings.Repeat("-", 72))
|
fmt.Println(strings.Repeat("-", 72))
|
||||||
@@ -116,9 +124,6 @@ func runHoneypotHits(ctx *cli.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
if filterIP != "" && r.IP != filterIP {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pwd := r.Password
|
pwd := r.Password
|
||||||
if pwd == "" {
|
if pwd == "" {
|
||||||
pwd = "(空)"
|
pwd = "(空)"
|
||||||
@@ -252,9 +257,103 @@ func runHoneypotPrune(ctx *cli.Context) error {
|
|||||||
return errors.Wrap(err, "prune honeypot records")
|
return errors.Wrap(err, "prune honeypot records")
|
||||||
}
|
}
|
||||||
fmt.Printf("已删除 %d 条过期蜜罐记录。\n", n)
|
fmt.Printf("已删除 %d 条过期蜜罐记录。\n", n)
|
||||||
|
// 回收物理空间:PostgreSQL 的 DELETE 只标记死元组,表文件不会变小。
|
||||||
|
// 不回收的话「清理」只是逻辑变小,磁盘占用继续单调增长。
|
||||||
|
if n > 0 {
|
||||||
|
if err := store.Compact(context.Background()); err != nil {
|
||||||
|
fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runHoneypotSize 报告蜜罐表的行数与磁盘占用。
|
||||||
|
//
|
||||||
|
// 这个命令存在的意义:把「会不会把数据库撑臃肿」变成运维随时能自己查的事,
|
||||||
|
// 而不是只能等开发一次性汇报。
|
||||||
|
func runHoneypotSize(ctx *cli.Context) error {
|
||||||
|
database, err := openDatabase()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store := db.NewHoneypotStore(database)
|
||||||
|
info, err := store.Size(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "query honeypot table size")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("蜜罐表体积")
|
||||||
|
fmt.Println(strings.Repeat("-", 72))
|
||||||
|
fmt.Printf(" 表名:honeypot_hits\n")
|
||||||
|
fmt.Printf(" 存活记录:%d 条\n", info.Rows)
|
||||||
|
if info.Bytes > 0 {
|
||||||
|
fmt.Printf(" 磁盘占用:%s(含索引)\n", humanBytes(info.Bytes))
|
||||||
|
if info.Rows > 0 {
|
||||||
|
fmt.Printf(" 单条均摊:%d 字节\n", info.Bytes/info.Rows)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" 磁盘占用:(当前数据库不支持元数据查询)\n")
|
||||||
|
}
|
||||||
|
if info.Oldest != nil && info.Newest != nil {
|
||||||
|
fmt.Printf(" 时间跨度:%s → %s\n",
|
||||||
|
info.Oldest.Format("2006-01-02 15:04"),
|
||||||
|
info.Newest.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 与配置中的两道闸对照,让运维一眼看出「当前是否在界内」。
|
||||||
|
maxRows := conf.Security.HoneypotMaxRows
|
||||||
|
retainDays := conf.Security.HoneypotRetainDays
|
||||||
|
if maxRows > 0 {
|
||||||
|
fmt.Printf("行数上限:%d 条(当前 %d 条,%s)\n", maxRows, info.Rows, usageRatio(info.Rows, int64(maxRows)))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("行数上限:未设置(当前 %d 条)\n", info.Rows)
|
||||||
|
}
|
||||||
|
if retainDays > 0 {
|
||||||
|
fmt.Printf("保留期:%d 天(自动清理 %s)\n", retainDays, onOff(conf.Security.HoneypotAutoClean))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageRatio 给出当前用量相对上限的百分比描述。
|
||||||
|
func usageRatio(cur int64, max int64) string {
|
||||||
|
if max <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pct := float64(cur) / float64(max) * 100
|
||||||
|
switch {
|
||||||
|
case pct >= 100:
|
||||||
|
return "已达上限"
|
||||||
|
case pct >= 80:
|
||||||
|
return "接近上限"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.1f%%", pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onOff(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "已开启"
|
||||||
|
}
|
||||||
|
return "已关闭"
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanBytes 把字节数格式化成人类可读形式。
|
||||||
|
func humanBytes(n int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if n < unit {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for v := n / unit; v >= unit; v /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
// maskSecret 对密码做保长脱敏:保留首尾各 1 位,中间固定长度星号。
|
// maskSecret 对密码做保长脱敏:保留首尾各 1 位,中间固定长度星号。
|
||||||
// 目的是让运维能分辨「是不是同一个密码」,但不把字典直接摊在终端上。
|
// 目的是让运维能分辨「是不是同一个密码」,但不把字典直接摊在终端上。
|
||||||
func maskSecret(s string) string {
|
func maskSecret(s string) string {
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ func runWeb(ctx *cli.Context) error {
|
|||||||
return errors.Wrap(err, "connect to database")
|
return errors.Wrap(err, "connect to database")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
|
||||||
|
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
||||||
|
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
|
||||||
|
stopRetention := make(chan struct{})
|
||||||
|
if conf.Security.HoneypotAutoClean {
|
||||||
|
db.NewHoneypotRetention(db.Honeypots, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
||||||
|
defer close(stopRetention)
|
||||||
|
}
|
||||||
|
|
||||||
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
|
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
|
||||||
|
|
||||||
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
|
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ var (
|
|||||||
HoneypotPaths string `ini:"honeypot_paths"`
|
HoneypotPaths string `ini:"honeypot_paths"`
|
||||||
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期由 CLI 清理。
|
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期由 CLI 清理。
|
||||||
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
||||||
|
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
|
||||||
|
// 默认 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||||
|
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
|
||||||
|
// 关闭后仍可手动执行 TamaBox honeypot prune。
|
||||||
|
HoneypotAutoClean bool `ini:"honeypot_auto_clean"`
|
||||||
|
// HoneypotMaxRows 蜜罐命中记录的行数上限。留空或 <=0 表示不设上限。
|
||||||
|
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
||||||
|
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||||
|
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
||||||
// HoneypotThreshold 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):
|
// HoneypotThreshold 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):
|
||||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
||||||
// 留空回退 1。
|
// 留空回退 1。
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 蜜罐表上无查询使用的索引(provider):GORM 去掉 tag 不会自动删既有索引,
|
||||||
|
// 这里显式清理一次。失败仅告警。
|
||||||
|
if err := dropUnusedHoneypotIndexes(db); err != nil {
|
||||||
|
logrus.WithError(err).Warn("清理蜜罐表无用索引时异常(不影响服务启动)")
|
||||||
|
}
|
||||||
|
|
||||||
Users = NewUsersStore(db)
|
Users = NewUsersStore(db)
|
||||||
Questions = NewQuestionsStore(db)
|
Questions = NewQuestionsStore(db)
|
||||||
FollowUps = NewFollowUpsStore(db)
|
FollowUps = NewFollowUpsStore(db)
|
||||||
@@ -215,6 +221,25 @@ func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dropUnusedHoneypotIndexes 删除蜜罐表上没有任何查询使用的索引。
|
||||||
|
//
|
||||||
|
// 背景:provider 字段原先带 gorm:"index",但全代码库没有一处按 provider
|
||||||
|
// 过滤或排序(TopSources 用 max(provider) 随 group by 顺带取,不需要独立索引)。
|
||||||
|
// 实测该索引占 10 万行约 648 kB,对一张「只增不减」的审计表是纯粹的空间浪费
|
||||||
|
// 与写放大。去掉 GORM tag 只影响新建库,既有库上的旧索引要显式 DROP。
|
||||||
|
//
|
||||||
|
// 幂等:索引不存在时不报错。失败仅告警,不阻断启动。
|
||||||
|
func dropUnusedHoneypotIndexes(db *gorm.DB) error {
|
||||||
|
// GORM 生成的索引名规则:idx_<表名>_<列名>。
|
||||||
|
if db.Migrator().HasIndex(&HoneypotHit{}, "idx_honeypot_hits_provider") {
|
||||||
|
if err := db.Migrator().DropIndex(&HoneypotHit{}, "idx_honeypot_hits_provider"); err != nil {
|
||||||
|
return errors.Wrap(err, "drop unused honeypot index")
|
||||||
|
}
|
||||||
|
logrus.Info("已删除蜜罐表上无查询使用的 provider 索引")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func fixPostgresSequences(db *gorm.DB) error {
|
func fixPostgresSequences(db *gorm.DB) error {
|
||||||
// 使用配置的 schema(未配置时回退 public),避免硬编码 nekobox。
|
// 使用配置的 schema(未配置时回退 public),避免硬编码 nekobox。
|
||||||
schema := conf.Database.Schema
|
schema := conf.Database.Schema
|
||||||
|
|||||||
+165
-1
@@ -45,7 +45,12 @@ type HoneypotHit struct {
|
|||||||
ContentType string `gorm:"size:256" json:"content_type"`
|
ContentType string `gorm:"size:256" json:"content_type"`
|
||||||
|
|
||||||
// Provider 为 IP 归属(云厂商/CDN/WAF)标签,Proxied 表示是否经代理。
|
// 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"`
|
Proxied bool `json:"proxied"`
|
||||||
|
|
||||||
// Banned 为命中时是否触发了封禁。
|
// Banned 为命中时是否触发了封禁。
|
||||||
@@ -64,6 +69,11 @@ type HoneypotStore interface {
|
|||||||
Create(ctx context.Context, hit *HoneypotHit) error
|
Create(ctx context.Context, hit *HoneypotHit) error
|
||||||
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
|
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
|
||||||
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
|
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 统计 since 之后的命中总数。
|
||||||
CountSince(ctx context.Context, since time.Time) (int64, error)
|
CountSince(ctx context.Context, since time.Time) (int64, error)
|
||||||
// TopSources 返回 since 之后命中次数最多的来源 IP。
|
// TopSources 返回 since 之后命中次数最多的来源 IP。
|
||||||
@@ -74,6 +84,34 @@ type HoneypotStore interface {
|
|||||||
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
|
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
|
||||||
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
|
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
|
||||||
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
|
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 聚合行。
|
// HoneypotSourceStat 来源 IP 聚合行。
|
||||||
@@ -125,6 +163,23 @@ func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) (
|
|||||||
return rows, err
|
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) {
|
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
|
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{})
|
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
|
||||||
return res.RowsAffected, res.Error
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("蜜罐记录空间回收失败(不影响数据正确性)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "回收空间不应影响数据正确性")
|
||||||
|
}
|
||||||
@@ -80,3 +80,50 @@ func TestHoneypotStore(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(2), total)
|
require.Equal(t, int64(2), total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHoneypotSize 覆盖体积报告:行数、时间跨度、磁盘占用。
|
||||||
|
//
|
||||||
|
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
|
||||||
|
func TestHoneypotSize(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: "9.9.9.9",
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/wp-login.php",
|
||||||
|
Username: "admin",
|
||||||
|
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := store.Size(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(3), info.Rows)
|
||||||
|
require.NotNil(t, info.Oldest)
|
||||||
|
require.NotNil(t, info.Newest)
|
||||||
|
// 最早的一条是 2 小时前,最新的一条是刚刚。
|
||||||
|
require.True(t, info.Newest.After(*info.Oldest))
|
||||||
|
// 磁盘占用在 PostgreSQL 下应当可查且不为零(表刚建,至少有元数据开销)。
|
||||||
|
require.Greater(t, info.Bytes, int64(0))
|
||||||
|
|
||||||
|
// 清理后行数应随之下降,但磁盘占用不保证立刻变小
|
||||||
|
// (PostgreSQL 的 DELETE 只标记死元组,需 VACUUM 才回收)。
|
||||||
|
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), deleted)
|
||||||
|
|
||||||
|
info2, err := store.Size(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(2), info2.Rows)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user