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,551 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user