Files
tamabox/internal/db/honeypot.go
T
tamakyi 818fa74160 fix(honeypot): 取证记精确地址、封禁用 /64 键,并补建封与拦截日志
线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
`2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己
仍能正常访问,看日志也无从判断到底有没有封。

根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值):
- 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账;
- 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。

改动:
- Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64):
  DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者;
- autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus),
  route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at;
  中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏);
- 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示),
  顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了;
- honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把
  排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。

测试(每项都做了变异验证:改坏→确认变红→恢复):
- TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、
  /64 才是封禁键、同 /64 换地址仍 403;
- TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调;
- TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复;
- TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
2026-10-23 10:53:03 +08:00

350 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 是发起请求的**精确**客户端地址(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
}
}