// 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(`
| `)
b.WriteString(` `)
fmt.Fprintf(&b, ` %s `, kpis[j][0])
fmt.Fprintf(&b, `%s `, kpis[j][1])
b.WriteString(` | `)
}
if i+1 >= len(kpis) {
b.WriteString(``) } b.WriteString(` |
| %s | `, align, align, h) } b.WriteString(`||
|---|---|---|
| %s | `, html.EscapeString(name)) fmt.Fprintf(&b, `%d | `, c.Hits) fmt.Fprintf(&b, `%s |