Files
tamabox/internal/db/honeypot.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

353 lines
13 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"
"time"
"gorm.io/gorm"
)
// HoneypotHit 是一次蜜罐命中记录(审计证据)。
//
// 刻意不嵌 dbutil.Model:审计证据不做软删除、不做 Update/Delete,
// 只追加与查询。理由与 ContentReport 一致 —— 证据链一旦被软删除过滤,
// 事后追责就断了。
type HoneypotHit struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
// IP 为归一化后的客户端 IP(shared.LimitKey 之后,IPv6 折叠到 /64)。
IP string `gorm:"size:64;index" json:"ip"`
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
Method string `gorm:"size:16" json:"method"`
Path string `gorm:"size:512" json:"path"`
// Query 保存原始查询串(截断)。
Query string `gorm:"size:1024" json:"query"`
// Username / Password 为提取出的破解凭据。Password 落库前做可逆加密还是脱敏,
// 由调用方决定:这里只存明文,CLI 展示时默认脱敏。
Username string `gorm:"size:256" json:"username"`
Password string `gorm:"size:256" json:"password"`
// Fields 保存其余表单/JSON 字段(JSON 字符串),便于还原破解工具特征。
Fields string `gorm:"type:text" json:"fields"`
// RawBody 保存截断后的原始 body。
RawBody string `gorm:"type:text" json:"raw_body"`
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
HadFields bool `json:"had_fields"`
// UserAgent / Referer / ContentType 用于扫描器画像。
UserAgent string `gorm:"size:512" json:"user_agent"`
Referer string `gorm:"size:512" json:"referer"`
ContentType string `gorm:"size:256" json:"content_type"`
// Provider 为 IP 归属(云厂商/CDN/WAF)标签,Proxied 表示是否经代理。
//
// ⚠️ 刻意不加索引:全代码库没有任何按 provider 过滤或排序的查询
// (TopSources 用 max(provider) 随 group by 顺带取,不需要独立索引)。
// 实测该索引占 10 万行约 648 kB,纯属写放大。既有库上的旧索引由
// dropUnusedHoneypotIndexes 在启动时清掉。
Provider string `gorm:"size:64" json:"provider"`
Proxied bool `json:"proxied"`
// Banned 为命中时是否触发了封禁。
Banned bool `json:"banned"`
}
// TableName 固定表名,避免 GORM 复数化差异。
func (HoneypotHit) TableName() string { return "honeypot_hits" }
// Honeypots 蜜罐命中记录全局 store,由 db.Init 装配。
var Honeypots HoneypotStore
// HoneypotStore 蜜罐命中记录的读写。
type HoneypotStore interface {
// Create 追加一条命中记录。
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。
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
// TopPaths 返回 since 之后被命中次数最多的路径。
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
// TopCredentials 返回 since 之后最常被尝试的用户名/密码组合。
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 聚合行。
type HoneypotSourceStat struct {
IP string
Count int64
Provider string
}
// HoneypotPathStat 路径聚合行。
type HoneypotPathStat struct {
Path string
Count int64
}
// HoneypotCredentialStat 凭据组合聚合行。
type HoneypotCredentialStat struct {
Username string
Password string
Count int64
}
type honeypots struct {
db *gorm.DB
}
// NewHoneypotStore 构造蜜罐记录 store。
func NewHoneypotStore(db *gorm.DB) HoneypotStore {
return &honeypots{db: db}
}
func (h *honeypots) Create(ctx context.Context, hit *HoneypotHit) error {
if hit == nil {
return nil
}
return h.db.WithContext(ctx).Create(hit).Error
}
func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
var rows []*HoneypotHit
err := h.db.WithContext(ctx).
Where("created_at >= ?", since).
Order("created_at DESC").
Limit(limit).
Find(&rows).Error
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{}).
Where("created_at >= ?", since).
Count(&n).Error
return n, err
}
func (h *honeypots) TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotSourceStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("ip AS ip, count(*) AS count, max(provider) AS provider").
Where("created_at >= ?", since).
Group("ip").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotPathStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("path AS path, count(*) AS count").
Where("created_at >= ?", since).
Group("path").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func (h *honeypots) TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotCredentialStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("username AS username, password AS password, count(*) AS count").
Where("created_at >= ?", since).
Where("username <> '' OR password <> ''").
Group("username, password").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
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
}
}