// 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(``) for i := 0; i < len(kpis); i += 2 { b.WriteString(``) for j := i; j < i+2 && j < len(kpis); j++ { b.WriteString(``) } if i+1 >= len(kpis) { b.WriteString(``) } b.WriteString(``) } b.WriteString(`
`) b.WriteString(`
`) fmt.Fprintf(&b, `
%s
`, kpis[j][0]) fmt.Fprintf(&b, `
%s
`, kpis[j][1]) b.WriteString(`
`) if sum.PrevHits > 0 { delta := float64(sum.TotalHits-sum.PrevHits) / float64(sum.PrevHits) * 100 arrow := "上升" if delta < 0 { arrow = "下降" } fmt.Fprintf(&b, `
较上一周期%s %.0f%%(上期 %d 次)。
`, 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(`
来源国家 / 地区(前 10)
`) b.WriteString(``) b.WriteString(``) for _, h := range []string{"国家/地区", "命中", "占比"} { align := "left" if h != "国家/地区" { align = "right" } fmt.Fprintf(&b, ``, align, align, h) } b.WriteString(``) 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, ``, html.EscapeString(name)) fmt.Fprintf(&b, ``, c.Hits) fmt.Fprintf(&b, ``, html.EscapeString(pctOf(c.Hits, sum.TotalHits))) } b.WriteString(`
%s
%s%d%s
`) 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
`, 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) }