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 个渲染测试失败。
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// 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"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SecurityReportLog 是「周期安全汇总已发送」的台账。
|
||||
//
|
||||
// 为什么需要一张表而不是在内存里记一个时间戳:
|
||||
// 服务会被重启(发版、OOM、机器迁移)。内存标记一重启就丢,而报告窗口是一整个
|
||||
// 自然周/月 —— 周一凌晨发过以后重启一次,下一次 tick 就会把同一周的周报再发一遍,
|
||||
// 连着发三次的情况在真实运维里很常见(发布窗口经常撞在周一)。
|
||||
//
|
||||
// 用 period_key 唯一索引做幂等:无论触发多少次、重启多少回,同一周期只发一次。
|
||||
type SecurityReportLog struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
|
||||
// PeriodKey 周期标识,形如 "week:2026-W44" / "month:2026-10"。
|
||||
// 唯一索引是幂等的唯一保证 —— 不要改成「按窗口时间范围查重」,
|
||||
// 那样跨月边界(一个月 4 周 vs 5 周)会出现漏发或重发的灰色地带。
|
||||
PeriodKey string `gorm:"size:32;uniqueIndex"`
|
||||
|
||||
// Kind 报告类型(week / month),便于按类型统计。
|
||||
Kind string `gorm:"size:8"`
|
||||
// WindowStart / WindowEnd 报告覆盖的时间窗口 [start, end)。
|
||||
WindowStart time.Time
|
||||
WindowEnd time.Time
|
||||
|
||||
// 发送时的关键数字快照。台账要能独立回答「那周的结论是什么」,
|
||||
// 而不是事后还得回去翻蜜罐表 —— 蜜罐表有保留期,过期就被清掉了。
|
||||
Hits int64
|
||||
UniqueSources int64
|
||||
Banned int64
|
||||
Countries int
|
||||
|
||||
Recipient string `gorm:"size:255"`
|
||||
// FilePath 报告 HTML 的落盘路径(留空表示只发了邮件、没留文件)。
|
||||
FilePath string `gorm:"size:512"`
|
||||
|
||||
// Sent 是否成功发出。失败的行保留下来,既是审计线索,
|
||||
// 也用来做「最多重试几次」的计数。
|
||||
Sent bool
|
||||
// Attempts 尝试次数。发送失败会重试,但必须设上限:
|
||||
// 一个配错的 SMTP 会让每次 tick 都失败,无限重试只是把日志刷满。
|
||||
Attempts int
|
||||
// Error 最近一次失败的原因(成功时清空)。
|
||||
Error string `gorm:"size:512"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName 固定表名。
|
||||
func (SecurityReportLog) TableName() string { return "security_report_logs" }
|
||||
|
||||
// SecurityReports 报告台账全局 store。
|
||||
var SecurityReports SecurityReportStore
|
||||
|
||||
// SecurityReportStore 报告台账的读写。
|
||||
type SecurityReportStore interface {
|
||||
// Get 按 period_key 取一条,未找到返回 (nil, nil)。
|
||||
Get(ctx context.Context, periodKey string) (*SecurityReportLog, error)
|
||||
// Save 按 period_key upsert。
|
||||
Save(ctx context.Context, rec *SecurityReportLog) error
|
||||
// Recent 返回最近 n 条,按窗口结束时间倒序。
|
||||
Recent(ctx context.Context, n int) ([]*SecurityReportLog, error)
|
||||
}
|
||||
|
||||
type securityReports struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSecurityReportStore 构造台账 store。
|
||||
func NewSecurityReportStore(db *gorm.DB) SecurityReportStore {
|
||||
return &securityReports{db: db}
|
||||
}
|
||||
|
||||
func (s *securityReports) Get(ctx context.Context, periodKey string) (*SecurityReportLog, error) {
|
||||
var rec SecurityReportLog
|
||||
err := s.db.WithContext(ctx).Where("period_key = ?", periodKey).First(&rec).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func (s *securityReports) Save(ctx context.Context, rec *SecurityReportLog) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "period_key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"kind", "window_start", "window_end", "hits", "unique_sources",
|
||||
"banned", "countries", "recipient", "file_path",
|
||||
"sent", "attempts", "error", "updated_at",
|
||||
}),
|
||||
}).
|
||||
Create(rec).Error
|
||||
}
|
||||
|
||||
func (s *securityReports) Recent(ctx context.Context, n int) ([]*SecurityReportLog, error) {
|
||||
if n <= 0 {
|
||||
n = 12
|
||||
}
|
||||
var rows []*SecurityReportLog
|
||||
err := s.db.WithContext(ctx).Order("window_end DESC").Limit(n).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
Reference in New Issue
Block a user