// 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 是发起请求的**精确**客户端地址(IPv6 保留完整地址,最长 45 字符)。 // // 刻意不存 shared.LimitKey 折叠后的 /64 聚合键:那张表是取证物, // 「哪台主机」必须留下来才能查其他日志、做 abuse 投诉。聚合键只适合 // 当计数器的 key(封禁 / 限流),它进不来这里。 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"` // HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。 // // ⚠️ 这里只存「有没有」这个事实,**不存凭据本身**。 // 用户名/密码/原始 body 全部落在 honeypot.CredStore(默认 // data/honeypot-creds/creds-YYYY-MM-DD.jsonl,权限 0600),原因见 // internal/security/honeypot/credstore.go 的 CredRecord 注释: // 明文账号密码不能进数据库 —— 一旦入库就会跟着进备份、主从复制、 // 以及任何有只读权限的报表工具,事后无法彻底清除。 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) // 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) // 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) // LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。 LegacyCredentialColumns(ctx context.Context) ([]string, error) // PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。 PurgeLegacyCredentialColumns(ctx context.Context) ([]string, 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 } // LegacyHoneypotCredentialColumns 是早期版本在 honeypot_hits 上留下的凭据列。 // // 结构体已经删掉这些字段,但 GORM 的 AutoMigrate **不会删除既有列** —— // 老库升级上来后列还在、历史明文也还在。启动时检测到就告警,由运维执行 // `TamaBox honeypot purge-creds` 显式清除(DROP COLUMN 不可逆,不自动做)。 var LegacyHoneypotCredentialColumns = []string{"username", "password", "fields", "raw_body"} // LegacyCredentialColumns 返回当前库里仍然存在的旧凭据列(空表示已清理干净)。 func (h *honeypots) LegacyCredentialColumns(ctx context.Context) ([]string, error) { var present []string for _, col := range LegacyHoneypotCredentialColumns { if h.db.WithContext(ctx).Migrator().HasColumn(&HoneypotHit{}, col) { present = append(present, col) } } return present, nil } // PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。 // // 语义是「把历史上已经入库的明文凭据彻底清掉」,所以是 DROP COLUMN 而不是 // 置空:置空后明文仍可能残留在表的死元组/备份里,DROP 至少让新产生的 // 备份与后续查询不再包含它们。真正的历史备份需要运维自行处理。 func (h *honeypots) PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error) { present, err := h.LegacyCredentialColumns(ctx) if err != nil { return nil, err } dropped := make([]string, 0, len(present)) for _, col := range present { if err := h.db.WithContext(ctx).Migrator().DropColumn(&HoneypotHit{}, col); err != nil { return dropped, err } dropped = append(dropped, col) } return dropped, nil } 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) 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) 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 } }