Files
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
2026-10-30 16:40:44 +08:00

427 lines
17 KiB
Go
Raw Permalink 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)
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// 安全汇总报告专用:报告要先按 IP 聚合再拿去地理定位,而不是把几十万条
// 命中明细全捞进内存再 group —— 明细只在「命中数」这一个维度上有用,
// 聚合能把它压到「独立 IP 数」的量级。
AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error)
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error)
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳(仅这一列)。
//
// 用于画每日趋势。刻意不在 SQL 里按天分组:DATE() 的返回类型在
// PostgreSQL(date)与 MySQL([]byte)之间不一致,跨方言解析很脆;
// 而这一列的体积极小(时间戳 8 字节),取回来在 Go 里按天归并更稳。
TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, 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
}
// HoneypotSourceAgg 是带「处置结果」的来源聚合行(安全汇总报告用)。
//
// 与 HoneypotSourceStat 的区别:那个是给 CLI 排行看的(只要次数),
// 这个要给报告回答「打了多少次 / 有多少次被拦下 / 有多少次真的在试密码」。
type HoneypotSourceAgg struct {
IP string
Hits int64
Banned int64
// Creds 带有凭据字段的命中数(真实破解尝试,区别于纯路径探测)。
Creds int64
// Provider 是该 IP 最近一次命中时的 cdncheck 标签(云厂商/CDN/WAF)。
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
}
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// [since, until) 左闭右开:周期报告的相邻两期不能有重叠,也不能漏掉
// 恰好落在整点上的记录 —— 用闭区间会让周一的 00:00:00 同时算进上周和本周。
func (h *honeypots) AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) {
var rows []*HoneypotSourceAgg
err := h.db.WithContext(ctx).
Table("honeypot_hits").
// SUM(CASE WHEN ...) 是 PG 与 MySQL 都支持的写法(boolean 在 PG 里
// 不能直接 sum,转 1/0 才跨方言)。
Select("ip AS ip, count(*) AS hits, "+
"sum(case when banned then 1 else 0 end) AS banned, "+
"sum(case when had_fields then 1 else 0 end) AS creds, "+
"max(provider) AS provider").
Where("created_at >= ? AND created_at < ?", since, until).
Group("ip").
Order("hits DESC").
Scan(&rows).Error
return rows, err
}
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
func (h *honeypots) AggregatePaths(ctx context.Context, since, until 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 >= ? AND created_at < ?", since, until).
Group("path").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳。
func (h *honeypots) TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) {
var ts []time.Time
err := h.db.WithContext(ctx).
Model(&HoneypotHit{}).
Where("created_at >= ? AND created_at < ?", since, until).
Order("created_at ASC").
Pluck("created_at", &ts).Error
return ts, 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
}
}