Files
tamabox/internal/cmd/report.go
T
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

552 lines
18 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 cmd
import (
"context"
"fmt"
"html"
"html/template"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/geoip"
"github.com/tamakyi/TamaBox/internal/security/report"
"github.com/tamakyi/TamaBox/internal/shared"
)
// reportCommand 是 honeypot report 子命令。
var reportCommand = &cli.Command{
Name: "report",
Usage: "Generate a weekly/monthly security summary (map + email)",
Description: "统计周期内的蜜罐命中,把来源 IP 通过公共 IP 库定位到城市,\n" +
" 汇总成带世界地图的报告,并(可选)发送到 security_alert_email。",
Flags: []cli.Flag{
&cli.StringFlag{Name: "period", Value: "week", Usage: "week or month"},
&cli.StringFlag{Name: "to", Usage: "Override recipient (default: honeypot_report_email / security_alert_email)"},
&cli.StringFlag{Name: "dir", Usage: "Output directory (default: honeypot_report_dir)"},
&cli.BoolFlag{Name: "no-email", Usage: "Only generate files, do not send email"},
},
Action: runHoneypotReport,
}
// reportResult 一次生成的产物。
type reportResult struct {
Summary *report.Summary
HTMLPath string
PNGPath string
To string
Sent bool
}
// runHoneypotReport 手动生成/补发一份报告。
//
// 存在的意义不只是「测一下能不能跑」:自动周报发出去以后,运维经常需要
// 「把上周的再发我一份」或「先出一份看看内容对不对」——手动入口必须与
// 自动入口走完全相同的代码路径,否则手动验证过的东西和线上跑的不是一回事。
func runHoneypotReport(ctx *cli.Context) error {
database, err := openDatabase()
if err != nil {
return err
}
now := time.Now()
var period report.Period
switch strings.ToLower(strings.TrimSpace(ctx.String("period"))) {
case "week", "w", "":
period = report.PreviousWeek(now)
case "month", "m":
period = report.PreviousMonth(now)
default:
return errors.Errorf("--period 只能是 week 或 month(收到 %q)", ctx.String("period"))
}
dir := ctx.String("dir")
if dir == "" {
dir = conf.Security.HoneypotReportDir
}
res, err := buildSecurityReport(context.Background(), database, period, dir)
if err != nil {
return err
}
printSecurityReport(res)
if ctx.Bool("no-email") {
fmt.Println()
fmt.Printf("已生成(未发信):\n %s\n %s\n", res.HTMLPath, res.PNGPath)
return nil
}
to := strings.TrimSpace(ctx.String("to"))
if to == "" {
to = reportRecipient()
}
if to == "" {
return errors.New("未配置收件人:请在 [security] 设置 honeypot_report_email 或 security_alert_email,或用 --to 指定")
}
if err := sendSecurityReport(res, to); err != nil {
return err
}
fmt.Printf("\n已发送到 %s\n", to)
return nil
}
// reportRecipient 返回报告收件人。
//
// 优先 honeypot_report_email,回退 security_alert_email —— 后者早就存在,
// 不给已有部署增加必填项;想分开投递的人再单独配前者。
func reportRecipient() string {
if v := strings.TrimSpace(conf.Security.HoneypotReportEmail); v != "" {
return v
}
return strings.TrimSpace(conf.Security.SecurityAlertEmail)
}
// buildSecurityReport 跑完整的「读数据 → 定位 → 聚合 → 落盘」流程。
func buildSecurityReport(cctx context.Context, database *gorm.DB, period report.Period, dir string) (*reportResult, error) {
store := db.NewHoneypotStore(database)
sources, err := store.AggregateSources(cctx, period.Start, period.End)
if err != nil {
return nil, errors.Wrap(err, "聚合蜜罐来源")
}
paths, err := store.AggregatePaths(cctx, period.Start, period.End, 20)
if err != nil {
return nil, errors.Wrap(err, "聚合被探测路径")
}
stamps, err := store.TimestampsBetween(cctx, period.Start, period.End)
if err != nil {
return nil, errors.Wrap(err, "读取命中时间戳")
}
// 上一周期同为等长窗口,用于「较上期 ↑/↓」。
prevHits, err := countBetween(cctx, store, period.Start.Add(-period.End.Sub(period.Start)), period.Start)
if err != nil {
return nil, errors.Wrap(err, "统计上一周期")
}
// 先按 LimitKey 合并,再只对合并后的键做定位 —— 顺序按命中数降序,
// 因为腾讯精修有配额上限,要优先花在最活跃的来源上。
keys := mergeKeysByHits(sources)
resolver := geoip.New(geoip.Options{
Provider: conf.Security.IPGeoProvider,
TencentKey: conf.Security.IPGeoTencentKey,
})
geo := report.ResolveWithCache(cctx, db.NewIPGeoStore(database), resolver, keys)
sum := report.Build(report.Input{
Period: period,
Sources: sources,
Paths: paths,
Timestamps: stamps,
Geo: geo,
PrevHits: prevHits,
ResolverDesc: resolver.Describe(),
Now: time.Now(),
})
htmlPath, pngPath, err := report.WriteFiles(sum, dir, report.HTMLOptions{
// 报告里的可交互地图用同一个 key(腾讯位置服务)。
TencentMapKey: conf.Security.IPGeoTencentKey,
})
if err != nil {
return nil, errors.Wrap(err, "写报告文件")
}
return &reportResult{Summary: sum, HTMLPath: htmlPath, PNGPath: pngPath}, nil
}
// countBetween 统计 [from, to) 的命中数。
func countBetween(cctx context.Context, store db.HoneypotStore, from, to time.Time) (int64, error) {
after, err := store.CountSince(cctx, from)
if err != nil {
return 0, err
}
upTo, err := store.CountSince(cctx, to)
if err != nil {
return 0, err
}
// CountSince 是「>= since」,两次相减即区间内数量;
// 时钟回拨等异常情况下可能出现负数,钳到 0。
if after < upTo {
return 0, nil
}
return after - upTo, nil
}
// mergeKeysByHits 把来源按封禁口径(LimitKey)合并,返回按命中数降序的键。
func mergeKeysByHits(sources []*db.HoneypotSourceAgg) []string {
hits := make(map[string]int64, len(sources))
for _, s := range sources {
hits[shared.LimitKey(s.IP)] += s.Hits
}
keys := make([]string, 0, len(hits))
for k := range hits {
keys = append(keys, k)
}
// 按命中数降序;同次数按键名升序保证顺序稳定 —— 否则每次跑报告时
// 「腾讯精修覆盖的那 N 个 IP」会随机漂移,两个周期的数据不可比。
sort.Slice(keys, func(i, j int) bool {
if hits[keys[i]] != hits[keys[j]] {
return hits[keys[i]] > hits[keys[j]]
}
return keys[i] < keys[j]
})
return keys
}
// sendSecurityReport 发信(内嵌 PNG 地图 + 附 HTML 报告)。
func sendSecurityReport(res *reportResult, to string) error {
sum := res.Summary
payload := mail.SecurityReportMail{
To: to,
Title: fmt.Sprintf("【狼的提问箱】%s", sum.Period.Title()),
Period: sum.Period.Label(),
Generated: sum.GeneratedAt.Format("2006-01-02 15:04"),
SummaryHTML: template.HTML(buildSummaryHTML(sum)),
CountriesHTML: template.HTML(buildCountriesHTML(sum)),
Notes: template.HTML(buildNotesHTML(sum)),
Attachments: []mail.MailAttachment{{
Path: res.HTMLPath,
Name: sum.Period.Filename() + ".html",
}},
}
// 内嵌地图:只有真的有命中才发(无数据时地图是一张空白经纬网,没有信息量)。
if !sum.Empty && len(sum.Cities) > 0 {
payload.MapCID = "honeypot-map"
payload.InlineImages = []mail.MailInlineImage{{
Path: res.PNGPath,
CID: "honeypot-map",
}}
}
if err := mail.SendSecurityReportMail(payload); err != nil {
return errors.Wrap(err, "发送报告邮件")
}
res.Sent = true
res.To = to
return nil
}
// printSecurityReport 在终端打印报告摘要(手动运行时看)。
func printSecurityReport(res *reportResult) {
sum := res.Summary
fmt.Println(sum.Period.Title())
fmt.Println(strings.Repeat("-", 72))
fmt.Printf(" 窗口:%s\n", sum.Period.Label())
fmt.Printf(" 命中:%d 次(上期 %d)\n", sum.TotalHits, sum.PrevHits)
fmt.Printf(" 独立来源:%d 个,覆盖 %d 个国家/地区\n", sum.UniqueSources, sum.UniqueCountry)
fmt.Printf(" 真实破解尝试:%d 次;已拦截:%d 次\n", sum.CredHits, sum.BannedHits)
if sum.UnlocatedHits > 0 {
fmt.Printf(" 未能定位:%d 次命中(%s)\n", sum.UnlocatedHits, pctOf(sum.UnlocatedHits, sum.TotalHits))
}
if len(sum.Countries) > 0 {
fmt.Println(" 主要来源:")
for i, c := range sum.Countries {
if i >= 8 {
break
}
fmt.Printf(" %-4s %s %6d 次 %s\n", c.Code, padDisplay(truncateForCLI(c.Name, 12), 20), c.Hits, pctOf(c.Hits, sum.TotalHits))
}
}
fmt.Println()
}
// ---- 邮件正文片段 ----
// buildSummaryHTML 生成 KPI 卡片(邮件只能用内联样式,表格布局最稳)。
func buildSummaryHTML(sum *report.Summary) string {
kpis := [][2]string{
{"蜜罐命中", fmt.Sprintf("%d 次", sum.TotalHits)},
{"独立来源", fmt.Sprintf("%d 个", sum.UniqueSources)},
{"涉及国家/地区", fmt.Sprintf("%d 个", sum.UniqueCountry)},
{"真实破解尝试", fmt.Sprintf("%d 次", sum.CredHits)},
{"已拦截", fmt.Sprintf("%d 次", sum.BannedHits)},
{"云主机来源", fmt.Sprintf("%d 个", sum.CloudSources)},
}
var b strings.Builder
b.WriteString(`<table width="100%" border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">`)
for i := 0; i < len(kpis); i += 2 {
b.WriteString(`<tr>`)
for j := i; j < i+2 && j < len(kpis); j++ {
b.WriteString(`<td width="50%" style="padding:6px;">`)
b.WriteString(`<div style="border:1px solid #dadce0;border-radius:8px;padding:12px 14px;">`)
fmt.Fprintf(&b, `<div style="font-size:12px;color:rgba(0,0,0,0.54);">%s</div>`, kpis[j][0])
fmt.Fprintf(&b, `<div style="font-size:20px;font-weight:600;color:#202124;margin-top:2px;">%s</div>`, kpis[j][1])
b.WriteString(`</div></td>`)
}
if i+1 >= len(kpis) {
b.WriteString(`<td width="50%"></td>`)
}
b.WriteString(`</tr>`)
}
b.WriteString(`</table>`)
if sum.PrevHits > 0 {
delta := float64(sum.TotalHits-sum.PrevHits) / float64(sum.PrevHits) * 100
arrow := "上升"
if delta < 0 {
arrow = "下降"
}
fmt.Fprintf(&b, `<div style="font-size:13px;color:rgba(0,0,0,0.6);margin-top:10px;">较上一周期%s %.0f%%(上期 %d 次)。</div>`,
arrow, absFloat(delta), sum.PrevHits)
}
return b.String()
}
// buildCountriesHTML 生成国家排行表。
func buildCountriesHTML(sum *report.Summary) string {
if len(sum.Countries) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<div style="font-size:13px;color:rgba(0,0,0,0.54);margin-bottom:8px;">来源国家 / 地区(前 10)</div>`)
b.WriteString(`<table width="100%" border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:13px;">`)
b.WriteString(`<tr>`)
for _, h := range []string{"国家/地区", "命中", "占比"} {
align := "left"
if h != "国家/地区" {
align = "right"
}
fmt.Fprintf(&b, `<th align="%s" style="text-align:right;padding:6px 8px;border-bottom:1px solid #dadce0;color:rgba(0,0,0,0.54);font-weight:600;text-align:%s;">%s</th>`,
align, align, h)
}
b.WriteString(`</tr>`)
for i, c := range sum.Countries {
if i >= 10 {
break
}
name := c.Name
if c.Code != "" && c.Code != "--" {
name = fmt.Sprintf("%s(%s)", c.Name, c.Code)
}
fmt.Fprintf(&b, `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f3f4;color:#202124;">%s</td>`,
html.EscapeString(name))
fmt.Fprintf(&b, `<td align="right" style="padding:6px 8px;border-bottom:1px solid #f1f3f4;text-align:right;color:#202124;">%d</td>`, c.Hits)
fmt.Fprintf(&b, `<td align="right" style="padding:6px 8px;border-bottom:1px solid #f1f3f4;text-align:right;color:rgba(0,0,0,0.6);">%s</td></tr>`,
html.EscapeString(pctOf(c.Hits, sum.TotalHits)))
}
b.WriteString(`</table>`)
return b.String()
}
// buildNotesHTML 生成口径说明。
func buildNotesHTML(sum *report.Summary) string {
lines := []string{
fmt.Sprintf("地理定位数据源:%s。", sum.ResolverDesc),
fmt.Sprintf("未能定位的命中:%d 次(占 %s)。", sum.UnlocatedHits, pctOf(sum.UnlocatedHits, sum.TotalHits)),
"「真实破解尝试」指提交了账号密码字段的请求;捕获到的账号密码不写进报告,只在服务器取证目录中(TamaBox honeypot hits 查看)。",
"「网络归属」来自离线指纹,判定云厂商 / CDN / WAF,与地理位置是两个维度:显示「未识别(普通网络)」不代表没定位到位置。",
}
var b strings.Builder
for _, l := range lines {
fmt.Fprintf(&b, `%s<br/>`, html.EscapeString(l))
}
return b.String()
}
func pctOf(v, total int64) string {
if total <= 0 {
return "—"
}
return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100)
}
func absFloat(f float64) float64 {
if f < 0 {
return -f
}
return f
}
// ---- 后台调度 ----
// SecurityReportScheduler 周期性检查是否有到期的周报/月报需要发送。
//
// 为什么不直接用 cron:「这一周的周报发过没」需要幂等保证,而幂等只能靠
// 数据库里的台账(security_report_logs 的 period_key 唯一索引)。有了台账,
// 调度器只需要「每半小时看一眼现在该发哪一期、发过没」——
// 服务中途重启、周一维护窗口错过、机器睡了一觉再醒来,都能正确补发。
type SecurityReportScheduler struct {
database *gorm.DB
interval time.Duration
// now 可注入(测试用)。
now func() time.Time
}
// NewSecurityReportScheduler 构造调度器。
func NewSecurityReportScheduler(database *gorm.DB) *SecurityReportScheduler {
return &SecurityReportScheduler{
database: database,
// 30 分钟一轮:周报/月报的时效要求是「周一/月初能看到」,
// 半小时的粒度远够,而且这个检查只读一条台账,成本几乎为零。
interval: 30 * time.Minute,
now: time.Now,
}
}
// Start 启动后台循环,stop 关闭时退出。
func (s *SecurityReportScheduler) Start(stop <-chan struct{}) {
if s == nil || s.database == nil {
return
}
go func() {
// 启动后先等 2 分钟再跑第一轮:让站点先完成启动、DB 连接池预热,
// 也避免与保留期清理抢同一时刻的数据库资源。
select {
case <-stop:
return
case <-time.After(2 * time.Minute):
}
s.tick()
t := time.NewTicker(s.interval)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
s.tick()
}
}
}()
logrus.WithField("interval", s.interval).Info("安全汇总报告调度器已启动")
}
// tick 检查并发送到期报告。
func (s *SecurityReportScheduler) tick() {
now := s.now()
periods := make([]report.Period, 0, 2)
if conf.Security.HoneypotReportWeekly {
periods = append(periods, report.PreviousWeek(now))
}
if conf.Security.HoneypotReportMonthly {
periods = append(periods, report.PreviousMonth(now))
}
for _, p := range periods {
if !s.due(p, now) {
continue
}
if err := s.deliver(p, now); err != nil {
logrus.WithError(err).WithField("period", p.Key).Warn("安全汇总报告发送失败")
}
}
}
// due 判断某周期现在是否应该发送。
//
// 条件:周期已结束超过 1 小时(留出跨零点写入的余量),且尚未成功发送。
// 不限制「过期多久就不发」:一台停机两周的机器重新上线时,补发上周的周报
// 仍然有意义 —— 反倒是「因为晚了就永远不发」会造成数据黑洞。
func (s *SecurityReportScheduler) due(p report.Period, now time.Time) bool {
if now.Before(p.End) {
return false
}
rec, err := db.NewSecurityReportStore(s.database).Get(context.Background(), p.Key)
if err != nil {
logrus.WithError(err).WithField("period", p.Key).Warn("读取报告台账失败,本轮跳过")
return false
}
if rec == nil {
return true
}
if rec.Sent {
return false
}
// 失败重试上限:SMTP 配错时每次 tick 都失败,无限重试只会把日志和邮箱
// 配额消耗干净。5 次之后停下来,等人工介入(CLI 手动补发会重置计数)。
const maxAttempts = 5
return rec.Attempts < maxAttempts
}
// deliver 生成并发送一份报告,同时写台账。
func (s *SecurityReportScheduler) deliver(p report.Period, now time.Time) error {
store := db.NewSecurityReportStore(s.database)
// 先读一次台账(可能为 nil),用于累计尝试次数。
rec, err := store.Get(context.Background(), p.Key)
if err != nil {
return errors.Wrap(err, "读取台账")
}
if rec == nil {
rec = &db.SecurityReportLog{PeriodKey: p.Key, Kind: p.Kind}
}
rec.WindowStart, rec.WindowEnd = p.Start, p.End
rec.Attempts++
to := reportRecipient()
if to == "" {
rec.Error = "未配置收件人(honeypot_report_email / security_alert_email 均为空)"
_ = store.Save(context.Background(), rec)
return errors.New(rec.Error)
}
rec.Recipient = to
res, err := buildSecurityReport(context.Background(), s.database, p, conf.Security.HoneypotReportDir)
if err != nil {
rec.Error = truncateForCLI(err.Error(), 480)
_ = store.Save(context.Background(), rec)
return err
}
rec.Hits = res.Summary.TotalHits
rec.UniqueSources = int64(res.Summary.UniqueSources)
rec.Banned = res.Summary.BannedHits
rec.Countries = res.Summary.UniqueCountry
rec.FilePath = res.HTMLPath
if err := sendSecurityReport(res, to); err != nil {
rec.Error = truncateForCLI(err.Error(), 480)
_ = store.Save(context.Background(), rec)
return err
}
rec.Sent = true
rec.Error = ""
if err := store.Save(context.Background(), rec); err != nil {
// 台账写失败意味着下一轮会重复发送。这里必须大声报出来,
// 否则用户会收到同一周的周报两三次而不知道原因。
logrus.WithError(err).WithField("period", p.Key).
Error("报告已发送但台账写入失败:下一轮可能重复发送同一期报告")
return nil
}
logrus.WithFields(logrus.Fields{
"period": p.Key, "hits": rec.Hits, "to": to, "file": rec.FilePath,
}).Info("安全汇总报告已发送")
return nil
}
// padDisplay 按「显示列宽」右补空格,让 CLI 表格对齐。
//
// 不能用 %-20s:Go 的宽度按字符数算,一个汉字只算 1,终端里却占两格 ——
// 国家名改成中文之后,表格会歪得看不出哪一列是哪一列。
func padDisplay(s string, width int) string {
w := 0
for _, r := range s {
if r > 0x2E80 {
w += 2
} else {
w++
}
}
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}