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:
tamakyi
2026-10-30 16:40:44 +08:00
parent 08bbd17751
commit d690355cdc
33 changed files with 5198 additions and 1 deletions
+3
View File
@@ -25,6 +25,7 @@ import (
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
// TamaBox honeypot size 报告表行数与磁盘占用
// TamaBox honeypot report --period week 生成安全汇总周报(含地图)并发邮件
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
//
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
@@ -68,6 +69,8 @@ var Honeypot = &cli.Command{
Usage: "Report honeypot table row count and disk usage",
Action: runHoneypotSize,
},
// 周期安全汇总:把命中按 IP 定位到城市,出地图报告并邮件送达。
reportCommand,
{
Name: "paths",
Usage: "List active decoy paths (built-in + honeypot_paths)",
+551
View File
@@ -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)
}
+103
View File
@@ -0,0 +1,103 @@
// 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"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/security/report"
)
// TestMergeKeysByHits 覆盖「按封禁口径合并后再定位」的键生成。
//
// 顺序很重要:腾讯精修有配额上限,只处理前 N 个 —— 必须优先给最活跃的来源,
// 否则配额会花在只命中一次的长尾地址上,而报告的主要来源反而定位不到。
func TestMergeKeysByHits(t *testing.T) {
rows := []*db.HoneypotSourceAgg{
{IP: "2408:8207:1234:5678::1", Hits: 5},
{IP: "2408:8207:1234:5678::2", Hits: 3}, // 同 /64 → 合成 8
{IP: "203.0.113.7", Hits: 6},
{IP: "198.51.100.1", Hits: 1},
}
got := mergeKeysByHits(rows)
require.Len(t, got, 3)
// 8 次(合并后的 /64)> 6 次 > 1 次
require.Equal(t, "2408:8207:1234:5678::/64", got[0])
require.Equal(t, "203.0.113.7", got[1])
require.Equal(t, "198.51.100.1", got[2])
}
// TestMergeKeysByHitsStable 覆盖同命中数时的稳定排序。
//
// 不稳定的话,每次跑报告被腾讯精修覆盖的那批 IP 会随机漂移,
// 两个周期的定位精度就不一致了,对比起来像是数据变了。
func TestMergeKeysByHitsStable(t *testing.T) {
rows := []*db.HoneypotSourceAgg{
{IP: "203.0.113.9", Hits: 2},
{IP: "203.0.113.2", Hits: 2},
{IP: "203.0.113.5", Hits: 2},
}
first := mergeKeysByHits(rows)
for i := 0; i < 20; i++ {
require.Equal(t, first, mergeKeysByHits(rows), "同命中数时顺序必须稳定")
}
}
// TestSchedulerDue 覆盖周报/月报的发送判定,这是「不重复发、不永远不发」的核心。
func TestSchedulerDue(t *testing.T) {
database, cleanup := dbutil.NewTestDB(t, db.AllTables...)
t.Cleanup(func() {
if err := cleanup("security_report_logs"); err != nil {
t.Fatal(err)
}
})
s := NewSecurityReportScheduler(database)
ctx := context.Background()
loc := time.Local
// now = 2026-10-26(周一)02:00,上一周是 10-19 ~ 10-25
now := time.Date(2026, 10, 26, 2, 0, 0, 0, loc)
period := report.PreviousWeek(now)
require.Equal(t, "week:2026-W43", period.Key)
// 1) 台账里没有记录 → 该发
require.True(t, s.due(period, now))
// 2) 已成功发送 → 不再发(哪怕重启一百次)
store := db.NewSecurityReportStore(database)
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End, Sent: true, Attempts: 1,
}))
require.False(t, s.due(period, now))
// 3) 发送失败但未超重试上限 → 继续重试
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End,
Sent: false, Attempts: 3, Error: "smtp auth failed",
}))
require.True(t, s.due(period, now))
// 4) 超过重试上限 → 停下来等人工介入,避免刷爆日志与邮箱配额
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End,
Sent: false, Attempts: 5, Error: "smtp auth failed",
}))
require.False(t, s.due(period, now))
// 5) 周期还没结束 → 不发(哪怕台账里没有记录)
future := report.PreviousWeek(now.AddDate(0, 0, 7))
require.False(t, s.due(future, now), "窗口未结束时不能发不完整的报告")
}
+14 -1
View File
@@ -95,7 +95,11 @@ func runWeb(ctx *cli.Context) error {
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
// 两个后台任务(保留期清理 / 周期报告)共用一个停止信号,
// defer 必须放在 if 外面 —— 否则「自动清理关掉、报告开着」时这个
// channel 永远不会被关闭,那个 goroutine 就一直挂着。
stopRetention := make(chan struct{})
defer close(stopRetention)
if conf.Security.HoneypotAutoClean {
var credPurge db.CredPurgeable
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
@@ -104,7 +108,16 @@ func runWeb(ctx *cli.Context) error {
credPurge = cs
}
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
defer close(stopRetention)
}
// 周期安全汇总(周报 / 月报):把命中按 IP 定位到城市,出地图报告并邮件送达。
// 幂等靠 security_report_logs 的 period_key 唯一索引,所以这里只管定期看一眼
// 「该发的那期发过没」—— 重启、错过时间窗都能正确补发,也不会重复发。
if conf.Security.HoneypotReportEnabled {
if reportRecipient() == "" {
logrus.Warn("已启用安全汇总报告,但 honeypot_report_email 与 security_alert_email 都为空,将无法送达")
}
NewSecurityReportScheduler(gdb).Start(stopRetention)
}
// Redis 配了就必须连得上:session 建在它上面,宕机等于全站 500(含匿名访客)。
+28
View File
@@ -43,6 +43,13 @@ const (
//
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
DefaultHoneypotCredDir = "data/honeypot-creds"
// DefaultIPGeoProvider 默认的地理定位数据源:auto(ip-api 打底 + 腾讯精修)。
DefaultIPGeoProvider = "auto"
// DefaultHoneypotReportDir 安全汇总报告文件的默认目录。
//
// 与凭据目录分开:报告里有攻击者 IP 明细,但它是可以外发/归档的材料;
// 凭据目录里是明文账号密码,永不外发。放在一起容易误打包。
DefaultHoneypotReportDir = "data/reports"
)
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
@@ -109,4 +116,25 @@ func sanitizeHoneypot() {
if strings.TrimSpace(Security.HoneypotCredDir) == "" {
Security.HoneypotCredDir = DefaultHoneypotCredDir
}
// 地理定位数据源:未配置 → auto。
// 显式写了不认识的值时保持原样,由 geoip 层解析并告警 ——
// 在配置层悄悄改成 auto 会让人以为配置生效了。
if strings.TrimSpace(Security.IPGeoProvider) == "" {
Security.IPGeoProvider = DefaultIPGeoProvider
}
// 周期报告:三个开关都用 HasKey 判定缺省值(同 HoneypotAutoClean 的理由)。
if !sec.HasKey("honeypot_report_enabled") {
Security.HoneypotReportEnabled = true
}
if !sec.HasKey("honeypot_report_weekly") {
Security.HoneypotReportWeekly = true
}
if !sec.HasKey("honeypot_report_monthly") {
Security.HoneypotReportMonthly = true
}
if strings.TrimSpace(Security.HoneypotReportDir) == "" {
Security.HoneypotReportDir = DefaultHoneypotReportDir
}
}
+12
View File
@@ -50,6 +50,18 @@ var keyAliases = map[string]string{
"honeypot_retentionday": "honeypot_retain_days",
"honeypot_keep_days": "honeypot_retain_days",
"honeypot_retain_max_days": "honeypot_retain_days",
// 地理定位与周期报告:这几个键都是新增的,最容易被写成不同的词。
"ipgeo_provider": "ip_geo_provider",
"ip_geolocation_provider": "ip_geo_provider",
"geo_provider": "ip_geo_provider",
"ip_geo_key": "ip_geo_tencent_key",
"ip_geo_tencent_apikey": "ip_geo_tencent_key",
"tencent_map_key": "ip_geo_tencent_key",
"honeypot_report": "honeypot_report_enabled",
"honeypot_report_mail": "honeypot_report_email",
"honeypot_report_to": "honeypot_report_email",
"honeypot_reports_dir": "honeypot_report_dir",
"honeypot_report_path": "honeypot_report_dir",
}
// normalizeKeyName 归一化配置键名。
+36
View File
@@ -152,6 +152,42 @@ var (
// CSPReportURI 违规上报地址(可选)。留空时浏览器只把违规打到 console 不上报;
// 用 report 模式观察时应当配上,否则「观察一周」就只是空谈。
CSPReportURI string `ini:"csp_report_uri"`
// IPGeoProvider 蜜罐/扫描来源 IP 的地理定位数据源。
//
// ⚠️ 与 ipinfo.Lookup(cdncheck 离线指纹)是两件事:那套只认云厂商/CDN/WAF
// 的 ASN,肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
// 这里回答的是「这个 IP 在哪儿」,靠的是外部 IP 库。
//
// auto(缺省):ip-api.com 批量打底(全球覆盖、无需 key、一次 100 个),
// 配了 ip_geo_tencent_key 时再用腾讯位置服务精修国内 IP
// (省市 + GCJ-02 坐标)。腾讯的 IP 定位是一 IP 一请求,
// 拿它做批量主干会把 N 个 IP 变成 N 次 HTTP,故只做精修。
// ipapi:只用 ip-api.com
// tencent:只用腾讯位置服务(要求配 key,否则降级为 ipapi)
// none:不查询,报告里只给 IP 不提地理信息(攻击者 IP 一个字节都不外发)
//
// 隐私边界:auto/ipapi 会把攻击者 IP 发给 ip-api.com,tencent 会发给腾讯。
// 本报告只处理「攻击者」地址,不涉及任何用户 IP;不接受这一点请用 none。
IPGeoProvider string `ini:"ip_geo_provider"`
// IPGeoTencentKey 腾讯位置服务 key(lbs.qq.com 申请)。留空则跳过腾讯精修。
// 仅用于服务端 HTTP 请求,不下发到前端 —— 与地图 JS 用的 key 不是一回事。
IPGeoTencentKey string `ini:"ip_geo_tencent_key"`
// HoneypotReportEnabled 是否启用周期性安全状况汇总(周报 / 月报)邮件。
// 缺省 true:报告本身是运维最需要的「有没有人在打、从哪儿打」,
// 而收件人缺失时会 WARN 跳过,不会静默。
HoneypotReportEnabled bool `ini:"honeypot_report_enabled"`
// HoneypotReportWeekly / Monthly 分别控制周报与月报,缺省都开。
HoneypotReportWeekly bool `ini:"honeypot_report_weekly"`
HoneypotReportMonthly bool `ini:"honeypot_report_monthly"`
// HoneypotReportEmail 报告收件人。留空回退 security_alert_email。
// 单独开一个键是为了能把「随时可能发生的封禁告警」与「定期汇总」
// 分投到不同邮箱(例如告警进手机推送邮箱、汇总进归档邮箱)。
HoneypotReportEmail string `ini:"honeypot_report_email"`
// HoneypotReportDir 报告文件(HTML + PNG)的存放目录,缺省 data/reports。
// 报告里有攻击者 IP 明细,目录权限按 0700 创建。
HoneypotReportDir string `ini:"honeypot_report_dir"`
}
Server struct {
+4
View File
@@ -45,6 +45,10 @@ var AllTables = []interface{}{
&UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{},
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
&IPGeoRecord{},
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
&SecurityReportLog{},
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
&BanRecord{},
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
+77
View File
@@ -97,6 +97,20 @@ type HoneypotStore interface {
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
Size(ctx context.Context) (HoneypotSize, error)
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// 安全汇总报告专用:报告要先按 IP 聚合再拿去地理定位,而不是把几十万条
// 命中明细全捞进内存再 group —— 明细只在「命中数」这一个维度上有用,
// 聚合能把它压到「独立 IP 数」的量级。
AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error)
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error)
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳(仅这一列)。
//
// 用于画每日趋势。刻意不在 SQL 里按天分组:DATE() 的返回类型在
// PostgreSQL(date)与 MySQL([]byte)之间不一致,跨方言解析很脆;
// 而这一列的体积极小(时间戳 8 字节),取回来在 Go 里按天归并更稳。
TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error)
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
LegacyCredentialColumns(ctx context.Context) ([]string, error)
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
@@ -121,6 +135,20 @@ type HoneypotSourceStat struct {
Provider string
}
// HoneypotSourceAgg 是带「处置结果」的来源聚合行(安全汇总报告用)。
//
// 与 HoneypotSourceStat 的区别:那个是给 CLI 排行看的(只要次数),
// 这个要给报告回答「打了多少次 / 有多少次被拦下 / 有多少次真的在试密码」。
type HoneypotSourceAgg struct {
IP string
Hits int64
Banned int64
// Creds 带有凭据字段的命中数(真实破解尝试,区别于纯路径探测)。
Creds int64
// Provider 是该 IP 最近一次命中时的 cdncheck 标签(云厂商/CDN/WAF)。
Provider string
}
// HoneypotPathStat 路径聚合行。
type HoneypotPathStat struct {
Path string
@@ -234,6 +262,55 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
return rows, err
}
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// [since, until) 左闭右开:周期报告的相邻两期不能有重叠,也不能漏掉
// 恰好落在整点上的记录 —— 用闭区间会让周一的 00:00:00 同时算进上周和本周。
func (h *honeypots) AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) {
var rows []*HoneypotSourceAgg
err := h.db.WithContext(ctx).
Table("honeypot_hits").
// SUM(CASE WHEN ...) 是 PG 与 MySQL 都支持的写法(boolean 在 PG 里
// 不能直接 sum,转 1/0 才跨方言)。
Select("ip AS ip, count(*) AS hits, "+
"sum(case when banned then 1 else 0 end) AS banned, "+
"sum(case when had_fields then 1 else 0 end) AS creds, "+
"max(provider) AS provider").
Where("created_at >= ? AND created_at < ?", since, until).
Group("ip").
Order("hits DESC").
Scan(&rows).Error
return rows, err
}
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
func (h *honeypots) AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotPathStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("path AS path, count(*) AS count").
Where("created_at >= ? AND created_at < ?", since, until).
Group("path").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳。
func (h *honeypots) TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) {
var ts []time.Time
err := h.db.WithContext(ctx).
Model(&HoneypotHit{}).
Where("created_at >= ? AND created_at < ?", since, until).
Order("created_at ASC").
Pluck("created_at", &ts).Error
return ts, err
}
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
+70
View File
@@ -159,3 +159,73 @@ func TestHoneypotSize(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(2), info2.Rows)
}
// TestHoneypotAggregateRange 覆盖安全汇总报告用的区间聚合。
//
// 三个方法共同回答「这一周谁在打、打了哪儿」。重点验证:
// - [since, until) 左闭右开,相邻两期不能重叠;
// - SUM(CASE WHEN ...) 在 PostgreSQL / MySQL 上都能算出处置数;
// - 只取时间戳那一列时不夹带整行(报告不需要 UA / 路径以外的字段)。
func TestHoneypotAggregateRange(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 7)
rows := []*HoneypotHit{
// 窗口内:IP A 三次(两次被拦、一次带凭据)、IP B 一次
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(time.Hour)},
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: true, Provider: "Azure", CreatedAt: start.Add(2 * time.Hour)},
{IP: "203.0.113.1", Path: "/.env", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(3 * time.Hour)},
{IP: "198.51.100.2", Path: "/wp-admin", Banned: false, HadFields: false, CreatedAt: start.Add(4 * time.Hour)},
// 窗口外(右侧开区间边界):不能算进本期
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, CreatedAt: end},
// 窗口外(早于起点)
{IP: "192.0.2.9", Path: "/phpmyadmin", Banned: true, CreatedAt: start.Add(-time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
sources, err := store.AggregateSources(ctx, start, end)
require.NoError(t, err)
require.Len(t, sources, 2)
// 按命中数降序:203.0.113.1 在前
require.Equal(t, "203.0.113.1", sources[0].IP)
require.Equal(t, int64(3), sources[0].Hits)
require.Equal(t, int64(3), sources[0].Banned)
require.Equal(t, int64(1), sources[0].Creds)
require.Equal(t, "Azure", sources[0].Provider)
require.Equal(t, "198.51.100.2", sources[1].IP)
require.Equal(t, int64(1), sources[1].Hits)
require.Equal(t, int64(0), sources[1].Banned)
paths, err := store.AggregatePaths(ctx, start, end, 10)
require.NoError(t, err)
require.Len(t, paths, 3)
require.Equal(t, "/wp-login.php", paths[0].Path)
require.Equal(t, int64(2), paths[0].Count)
stamps, err := store.TimestampsBetween(ctx, start, end)
require.NoError(t, err)
require.Len(t, stamps, 4, "右边界那条不能算进来")
for _, ts := range stamps {
require.True(t, ts.Before(end), "所有时间戳都应严格早于 end")
}
// 空窗口必须返回空而不是报错(报告要能生成「本周无人扫描」)
empty, err := store.AggregateSources(ctx, start.AddDate(0, 0, 30), start.AddDate(0, 0, 37))
require.NoError(t, err)
require.Empty(t, empty)
}
+151
View File
@@ -0,0 +1,151 @@
// 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"
)
// IPGeoRecord 是一个来源 IP 的地理定位缓存。
//
// 为什么必须落库而不是每次现查:
// - 免费 IP 库都有配额(ip-api 免费版 45 次/分钟)。一次周报可能有几百上千个
// 不同来源 IP,现查必然撞限流,撞了就是「报告里一半 IP 没有位置」;
// - 同一个攻击者会在多份报告里反复出现,缓存能让第二次之后零外部请求;
// - 定位结果本身变化很慢(国家级几乎不变,城市级极少变),没必要重复付费。
//
// 缓存键用 shared.LimitKey(IPv4 精确、IPv6 折叠 /64)而不是原始 IP:
// 攻击者常在一个 /64 里轮换地址,按精确地址缓存会把同一个 C 段查上几十次,
// 既浪费配额又得到一模一样的结果。
type IPGeoRecord struct {
// IPKey 是缓存键(shared.LimitKey 的产物)。主键,不用自增 id:
// 这张表是纯 KV 缓存,没有任何按 id 遍历或引用的需求。
IPKey string `gorm:"size:64;primarykey"`
Country string `gorm:"size:64"`
CountryCode string `gorm:"size:8"`
Region string `gorm:"size:128"`
City string `gorm:"size:128"`
// ISP 运营商 / 网络名("China Telecom"、"Amazon.com, Inc.")。肉鸡报告里
// 这个字段比国家更有信息量:能看出是家宽还是云主机。
ISP string `gorm:"size:160"`
// ASN 自治域号("AS4134")。同 ASN 大量命中通常意味着同一片被控网络。
ASN string `gorm:"size:64"`
// Lat / Lon 为定位坐标。坐标系随来源不同:
// tencent → GCJ-02(火星坐标,与腾讯地图对齐)
// ipapi → WGS-84
// 两者相差几百米,在世界地图气泡图尺度上无影响;放大到街道级才有意义。
Lat float64
Lon float64
// HasCoords 标记坐标是否可用。IP 库对国家级、部分省级结果不给坐标,
// 用 0,0 表示「未知」会被画到几内亚湾去 —— 必须显式区分。
HasCoords bool
// Source 记录这条数据来自哪个库(tencent / ipapi),便于事后判断精度。
Source string `gorm:"size:16"`
// Failed 标记查询失败。失败也要缓存:某个 IP 查不出来通常是确定性的
// (保留地址、库未收录),反复重试只会白白消耗配额。
Failed bool
ResolvedAt time.Time `gorm:"index"`
}
// TableName 固定表名。
func (IPGeoRecord) TableName() string { return "ip_geo_cache" }
// IPGeos 地理定位缓存全局 store。
var IPGeos IPGeoStore
// IPGeoStore 地理定位缓存的读写。
type IPGeoStore interface {
// GetMany 批量取缓存,返回 map[IPKey]*IPGeoRecord。未命中的键不出现在结果里。
GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error)
// UpsertMany 批量写入或覆盖。
UpsertMany(ctx context.Context, records []*IPGeoRecord) error
// PurgeResolvedBefore 删除 resolved_at 早于 before 的记录(缓存过期)。
//
// 注意「过期」与「失败」的 TTL 不同,由调用方分两次调:
// 成功结果留得久(90 天),失败结果留得短(3 天)—— 失败可能是临时故障
// (对方 API 超时),留太久等于永久放弃这个 IP。
PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error)
// Count 返回缓存条目数。
Count(ctx context.Context) (int64, error)
}
type ipGeos struct {
db *gorm.DB
}
// NewIPGeoStore 构造缓存 store。务必传 db.DB 而不是复用什么包级单例。
func NewIPGeoStore(db *gorm.DB) IPGeoStore {
return &ipGeos{db: db}
}
func (s *ipGeos) GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error) {
out := make(map[string]*IPGeoRecord, len(keys))
if len(keys) == 0 {
return out, nil
}
// 分批 IN 查询:PostgreSQL 的占位符上限是 65535,MySQL 受 max_allowed_packet
// 约束,一次塞上千个键虽然多数情况能过,但没必要赌。
const batch = 500
for start := 0; start < len(keys); start += batch {
end := start + batch
if end > len(keys) {
end = len(keys)
}
var rows []*IPGeoRecord
if err := s.db.WithContext(ctx).
Where("ip_key IN ?", keys[start:end]).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out[r.IPKey] = r
}
}
return out, nil
}
func (s *ipGeos) UpsertMany(ctx context.Context, records []*IPGeoRecord) error {
if len(records) == 0 {
return nil
}
// 只更新定位字段,不更新 ip_key 本身(它是冲突判定列)。
cols := []string{
"country", "country_code", "region", "city", "isp", "asn",
"lat", "lon", "has_coords", "source", "failed", "resolved_at",
}
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "ip_key"}},
DoUpdates: clause.AssignmentColumns(cols),
}).
CreateInBatches(records, 100).Error
}
func (s *ipGeos) PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error) {
q := s.db.WithContext(ctx).Where("resolved_at < ?", before)
if onlyFailed {
q = q.Where("failed = ?", true)
} else {
// 成功记录那一轮不要连失败的一起删:两者的 TTL 不同。
q = q.Where("failed = ?", false)
}
res := q.Delete(&IPGeoRecord{})
return res.RowsAffected, res.Error
}
func (s *ipGeos) Count(ctx context.Context) (int64, error) {
var n int64
err := s.db.WithContext(ctx).Model(&IPGeoRecord{}).Count(&n).Error
return n, err
}
+174
View File
@@ -0,0 +1,174 @@
// 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"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestIPGeoStore 覆盖地理定位缓存的读写、批量取、覆盖与过期清理。
//
// 这张表是配额的保护层:同一批攻击者会在多份报告里反复出现,
// 「查一次、之后全靠缓存」能不能成立全看这里的语义对不对。
func TestIPGeoStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ip_geo_cache"); err != nil {
t.Fatal(err)
}
})
store := NewIPGeoStore(database)
now := time.Now()
records := []*IPGeoRecord{
{
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
Region: "Virginia", City: "Ashburn", ISP: "Amazon", ASN: "AS14618",
Lat: 39.04, Lon: -77.49, HasCoords: true, Source: "ipapi", ResolvedAt: now,
},
{
IPKey: "2001:db8::/64", Country: "中国", CountryCode: "CN",
Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4,
HasCoords: true, Source: "tencent", ResolvedAt: now,
},
{
// 查不到:Failed 标记要能落库,否则每次报告都会重试同一批地址
IPKey: "198.51.100.9", Failed: true, ResolvedAt: now,
},
}
require.NoError(t, store.UpsertMany(ctx, records))
got, err := store.GetMany(ctx, []string{"203.0.113.1", "2001:db8::/64", "198.51.100.9", "10.0.0.1"})
require.NoError(t, err)
require.Len(t, got, 3, "不存在的键不应出现在结果里")
require.Equal(t, "Ashburn", got["203.0.113.1"].City)
require.Equal(t, "AS14618", got["203.0.113.1"].ASN)
require.True(t, got["203.0.113.1"].HasCoords)
require.Equal(t, "tencent", got["2001:db8::/64"].Source)
require.True(t, got["198.51.100.9"].Failed)
// 覆盖写:同一个键再写一次应当更新而不是报主键冲突
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{{
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
City: "Reston", Lat: 38.96, Lon: -77.35, HasCoords: true,
Source: "tencent", ResolvedAt: now.Add(time.Hour),
}}))
got2, err := store.GetMany(ctx, []string{"203.0.113.1"})
require.NoError(t, err)
require.Equal(t, "Reston", got2["203.0.113.1"].City)
require.Equal(t, "tencent", got2["203.0.113.1"].Source)
n, err := store.Count(ctx)
require.NoError(t, err)
require.Equal(t, int64(3), n)
}
// TestIPGeoStorePurge 覆盖两套 TTL 分开清理:成功留 90 天、失败留 3 天。
func TestIPGeoStorePurge(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ip_geo_cache"); err != nil {
t.Fatal(err)
}
})
store := NewIPGeoStore(database)
now := time.Now()
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{
{IPKey: "1.0.0.1", Country: "澳大利亚", ResolvedAt: now.AddDate(0, 0, -100)}, // 成功且过期
{IPKey: "1.0.0.2", Country: "日本", ResolvedAt: now.AddDate(0, 0, -10)}, // 成功且新鲜
{IPKey: "1.0.0.3", Failed: true, ResolvedAt: now.AddDate(0, 0, -4)}, // 失败且过期
{IPKey: "1.0.0.4", Failed: true, ResolvedAt: now}, // 失败且新鲜
}))
// 清失败的旧记录:只删 1.0.0.3
n, err := store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -3), true)
require.NoError(t, err)
require.Equal(t, int64(1), n)
// 清成功的旧记录:只删 1.0.0.1(不能连失败的一起删,两者 TTL 不同)
n, err = store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -90), false)
require.NoError(t, err)
require.Equal(t, int64(1), n)
left, err := store.GetMany(ctx, []string{"1.0.0.1", "1.0.0.2", "1.0.0.3", "1.0.0.4"})
require.NoError(t, err)
require.Len(t, left, 2)
require.Contains(t, left, "1.0.0.2")
require.Contains(t, left, "1.0.0.4")
}
// TestSecurityReportStore 覆盖报告台账的幂等语义。
//
// 这是「重启不会重复发同一期周报」的唯一保证:period_key 唯一索引 + upsert。
func TestSecurityReportStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("security_report_logs"); err != nil {
t.Fatal(err)
}
})
store := NewSecurityReportStore(database)
start := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
end := start.AddDate(0, 0, 7)
// 首次:不存在
rec, err := store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.Nil(t, rec)
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "week:2026-W43", Kind: "week",
WindowStart: start, WindowEnd: end,
Hits: 42, UniqueSources: 7, Countries: 3,
Recipient: "[email protected]", Attempts: 1, Error: "smtp timeout",
}))
rec, err = store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.NotNil(t, rec)
require.False(t, rec.Sent)
require.Equal(t, 1, rec.Attempts)
// 第二次:同一 period_key 再写应当覆盖,而不是插入第二行 ——
// 否则「这一期发过没」会被多行记录搞乱。
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "week:2026-W43", Kind: "week",
WindowStart: start, WindowEnd: end,
Hits: 42, UniqueSources: 7, Countries: 3,
Recipient: "[email protected]", Attempts: 2, Sent: true,
}))
rec, err = store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.True(t, rec.Sent)
require.Equal(t, 2, rec.Attempts)
recent, err := store.Recent(ctx, 10)
require.NoError(t, err)
require.Len(t, recent, 1, "同一期只能有一行台账")
// 不同周期应各自一行
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "month:2026-10", Kind: "month",
WindowStart: start, WindowEnd: end, Sent: true,
}))
recent, err = store.Recent(ctx, 10)
require.NoError(t, err)
require.Len(t, recent, 2)
}
+118
View File
@@ -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
}
+112
View File
@@ -10,6 +10,7 @@ import (
"embed"
"fmt"
"html/template"
"path/filepath"
"strings"
"time"
@@ -247,12 +248,54 @@ func sendTemplateMail(email, title string, templateFS embed.FS, templatePath str
}
func sendMail(to, title, content string) error {
return sendMailWith(to, title, content, nil, nil)
}
// MailAttachment 邮件附件。
type MailAttachment struct {
Path string
// Name 附件显示名,留空则用文件名。
Name string
}
// MailInlineImage 正文内嵌图片。
//
// CID 是 Content-ID:正文 HTML 里写 <img src="cid:xxx">,邮件客户端就会用
// 附件里的这张图渲染。这是让「地图不依赖任何外链、不依赖 JS」的唯一可靠办法 ——
// 直接放外链图片,Gmail 默认不加载远程图片;放内联 SVG,Gmail/Outlook 会剥掉。
type MailInlineImage struct {
Path string
CID string
}
// sendMailWith 发送一封带内嵌图片与附件的 HTML 邮件。
//
// 图片与附件都从本地路径读取(调用方负责把文件写到临时目录或报告目录)。
// 任何一步失败都向上返回错误:报告这种「发了就该完整」的邮件,
// 静默丢附件比直接报错更糟。
func sendMailWith(to, title, content string, inline []MailInlineImage, atts []MailAttachment) error {
m := gomail.NewMessage()
m.SetHeader("From", fmt.Sprintf("TamaBox <%s>", conf.Mail.Account))
m.SetHeader("To", to)
m.SetHeader("Subject", title)
m.SetBody("text/html", content)
for _, img := range inline {
// 显式指定 Content-ID:gomail 的 Embed 默认会给一个随机值,
// 那样正文里就没法引用它。
m.Embed(img.Path, gomail.SetHeader(map[string][]string{
"Content-ID": {"<" + img.CID + ">"},
"Content-Disposition": {`inline; filename="` + filepath.Base(img.Path) + `"`},
}))
}
for _, a := range atts {
opts := []gomail.FileSetting{}
if a.Name != "" {
opts = append(opts, gomail.Rename(a.Name))
}
m.Attach(a.Path, opts...)
}
d := gomail.NewDialer(
conf.Mail.SMTP,
conf.Mail.Port,
@@ -265,3 +308,72 @@ func sendMail(to, title, content string) error {
}
return d.DialAndSend(m)
}
// SecurityReportMail 周期安全汇总邮件的载荷。
//
// HTML 片段用 template.HTML 传:报告正文里有 KPI 卡片和表格,
// 走 string 会被 html/template 转义成源码文本,收件人看到的是一堆尖括号。
type SecurityReportMail struct {
// To 收件人,由调用方决定(honeypot_report_email / security_alert_email)。
To string
Title string
Period string
Generated string
// MapCID 正文内嵌地图的 Content-ID;为空则不发地图(报告无数据时)。
MapCID string
// SummaryHTML KPI 卡片片段。
SummaryHTML template.HTML
// CountriesHTML 国家排行表片段。
CountriesHTML template.HTML
// Notes 口径说明。用 template.HTML 传:里面有多行 <br/> 结构。
Notes template.HTML
// Attachments 附件(报告 HTML)。
Attachments []MailAttachment
// InlineImages 内嵌图片(地图 PNG),其 CID 必须与 MapCID 一致。
InlineImages []MailInlineImage
}
// securityReportData 是模板的渲染载荷(独立成一个类型,方便单独测渲染)。
type securityReportData struct {
Title string
Period string
Generated string
MapCID string
SummaryHTML template.HTML
CountriesHTML template.HTML
Notes template.HTML
}
// SendSecurityReportMail 发送周期安全汇总邮件。
func SendSecurityReportMail(r SecurityReportMail) error {
content, err := RenderSecurityReportMail(r)
if err != nil {
return err
}
return sendMailWith(r.To, r.Title, content, r.InlineImages, r.Attachments)
}
// RenderSecurityReportMail 只渲染邮件正文,不发信。
//
// 单独拆出来是为了能在没有 SMTP 的环境里验证模板:模板里的字段名写错一个字母,
// 生产上表现为「报告发出去了但正文是空的」,而这封邮件一周才发一次,
// 等发现时已经缺了好几期。
func RenderSecurityReportMail(r SecurityReportMail) (string, error) {
t, err := template.ParseFS(templates.FS, "mail/security-report.html")
if err != nil {
return "", errors.Wrap(err, "parse template file")
}
var content bytes.Buffer
if err := t.Execute(&content, securityReportData{
Title: r.Title,
Period: r.Period,
Generated: r.Generated,
MapCID: r.MapCID,
SummaryHTML: r.SummaryHTML,
CountriesHTML: r.CountriesHTML,
Notes: r.Notes,
}); err != nil {
return "", errors.Wrap(err, "execute template")
}
return content.String(), nil
}
+52
View File
@@ -0,0 +1,52 @@
// 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 mail
import (
"html/template"
"testing"
"github.com/stretchr/testify/require"
)
// TestRenderSecurityReportMail 钉住报告邮件模板本身。
//
// 这封邮件一周才发一次,模板字段名写错的表现是「发出去了但正文空/漏块」,
// 很难在第一时间发现。这里同时验证两件容易出错的事:
// 1. HTML 片段必须原样插入(走 template.HTML),不能被转义成源码文本;
// 2. 地图走 cid: 引用,邮件客户端才会把它当内嵌图而不是外链。
func TestRenderSecurityReportMail(t *testing.T) {
out, err := RenderSecurityReportMail(SecurityReportMail{
Title: "2026 年 10 月 第 43 周安全状况周报",
Period: "2026-10-19 ~ 2026-10-25",
Generated: "2026-10-30 16:16",
MapCID: "map-43",
SummaryHTML: template.HTML(`<div class="kpi">命中 <strong>554</strong> 次</div>`),
CountriesHTML: template.HTML(`<table><tr><td>美国</td><td>160</td></tr></table>`),
Notes: template.HTML(`口径一<br/>口径二`),
})
require.NoError(t, err)
// HTML 片段原样出现
require.Contains(t, out, "<strong>554</strong>")
require.Contains(t, out, "<td>美国</td>")
require.Contains(t, out, "口径一<br/>口径二")
require.NotContains(t, out, "&lt;strong&gt;", "片段被转义了,收件人只会看到一堆尖括号")
// 地图按 Content-ID 引用
require.Contains(t, out, "cid:map-43")
// 模板里的变量都应已展开
require.NotContains(t, out, "{{")
require.Contains(t, out, "2026-10-19 ~ 2026-10-25")
}
// TestRenderSecurityReportMailNoMap 没有命中时不发地图,也就不该留一个坏图位。
func TestRenderSecurityReportMailNoMap(t *testing.T) {
out, err := RenderSecurityReportMail(SecurityReportMail{Title: "空报告"})
require.NoError(t, err)
require.NotContains(t, out, "cid:", "无数据时不该引用不存在的地图")
require.NotContains(t, out, "{{")
}
+14
View File
@@ -513,6 +513,20 @@ func (c *captureStore) PurgeLegacyCredentialColumns(context.Context) ([]string,
return nil, nil
}
// 周期报告用到的时间窗口聚合。这组用例不测它们(报告有自己的单测),
// 但接口扩展后必须补上,否则整个包编译不过。
func (c *captureStore) AggregateSources(context.Context, time.Time, time.Time) ([]*db.HoneypotSourceAgg, error) {
return nil, nil
}
func (c *captureStore) AggregatePaths(context.Context, time.Time, time.Time, int) ([]*db.HoneypotPathStat, error) {
return nil, nil
}
func (c *captureStore) TimestampsBetween(context.Context, time.Time, time.Time) ([]time.Time, error) {
return nil, nil
}
// TestHoneypotServesFamilySpecificPage 验证按路径分发到不同后台的伪装。
//
// 扫描器先按路径判断「这是什么后台」,再拿对应字段名去填表;
+636
View File
@@ -0,0 +1,636 @@
// 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 geoip 把 IP 解析成地理位置(国家 / 省 / 市 / 运营商 / 坐标)。
//
// 与 internal/security/ipinfo 的分工:ipinfo 用离线指纹判断「这个 IP 是不是
// 云厂商 / CDN / WAF 的出口」,对家宽、企业宽带、移动网络一律返回
// 「未识别(普通网络)」—— 它回答的是「是不是代理」,不是「在哪儿」。
// 肉鸡恰恰全是后一类地址,所以安全汇总必须另有一套真正的 IP 地理位置库。
//
// 数据源策略(多源回退):
// - ip-api.com:批量接口一次 100 个 IP,全球覆盖好,免费版无需 key。
// 用它做批量主干 —— 免费版限 45 次/分钟,但一次请求覆盖 100 个 IP,
// 实际吞吐足够,且把「N 个 IP」压成「N/100 次请求」。
// - 腾讯位置服务:一 IP 一请求,国内 IP 的省市准确度更高、坐标是 GCJ-02
// (与腾讯地图底图对齐)。只用来**精修**国内的 IP,不做批量主干 ——
// 拿它打批量会把几百个 IP 变成几百次 HTTP,既慢又容易撞配额。
//
// 隐私边界:会把攻击者 IP 发给上述第三方。这是刻意取舍(不查就画不出地图),
// 但调用方必须清楚 —— 本包只应处理攻击者地址,绝不要拿它去查真实用户 IP。
package geoip
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// Provider 常量。
const (
// ProviderAuto 缺省:ip-api 批量打底 + 腾讯精修国内。
ProviderAuto = "auto"
// ProviderIPAPI 只用 ip-api.com。
ProviderIPAPI = "ipapi"
// ProviderTencent 只用腾讯位置服务(无 key 时自动降级为 ipapi)。
ProviderTencent = "tencent"
// ProviderNone 不查询任何外部服务。
ProviderNone = "none"
)
const (
// ipAPIBatchSize ip-api 批量接口的硬上限。
ipAPIBatchSize = 100
// ipAPIMinInterval ip-api 免费版限流 45 次/分钟。取 1.4s 间隔(≈43 次/分钟)
// 留一点余量:多个报告同时跑、或用户手动跑 CLI 时不会互相挤爆。
ipAPIMinInterval = 1400 * time.Millisecond
// defaultTencentRefineCap 腾讯精修最多处理多少个 IP。
//
// 必须有上限:周报可能有两千个不同来源 IP,其中一半是国内 —— 逐个请求
// 腾讯会跑十几分钟并吃掉当日免费配额,而报告的价值并不取决于把最后一千个
// 长尾 IP 精确到市。取命中次数最高的前 N 个,够回答「主要从哪儿来」。
defaultTencentRefineCap = 200
// defaultTimeout 单次外部请求超时。
//
// ⚠️ 不能设小。实测 ip-api 免费版的批量接口「22 个 IP」要 4–10 秒,
// 且同一批重复请求会更慢(免费额度在按 IP 做节流)。最早设 15 秒时
// 第一轮真实数据就超时了 —— 免费服务不是快服务,超时要按最坏情况给。
defaultTimeout = 45 * time.Second
// ipAPIAttempts 单个批次最多尝试几次。
// 免费接口偶发慢响应/网关抖动很常见,重试一次成本可接受(有 1.4s 节流兜底),
// 但重试多了只会放大超时。
ipAPIAttempts = 2
// ipAPIRetryDelay 重试前的等待。
ipAPIRetryDelay = 3 * time.Second
)
// Geo 一个 IP 的地理定位结果。
type Geo struct {
Country string
CountryCode string
Region string
City string
ISP string
ASN string
Lat float64
Lon float64
HasCoords bool
Source string
}
// Options 解析器配置。
type Options struct {
// Provider auto / ipapi / tencent / none,空视为 auto。
Provider string
// TencentKey 腾讯位置服务 key,留空则跳过精修。
TencentKey string
// TencentRefineCap 腾讯精修上限,<=0 用默认值。
TencentRefineCap int
// HTTPClient 可注入(测试用),nil 用带超时的默认 client。
HTTPClient *http.Client
}
// Resolver 按配置解析 IP 地理位置。
type Resolver struct {
provider string
tencent string
refineCap int
client *http.Client
// lastReq 串行化 ip-api 请求以遵守限流(多个报告并发时也不会超频)。
mu sync.Mutex
lastReq time.Time
}
// New 构造解析器。provider 值不认识时回退 auto 并告警 ——
// 静默回退会让人以为配置生效了,必须留痕。
func New(opts Options) *Resolver {
p := strings.ToLower(strings.TrimSpace(opts.Provider))
switch p {
case ProviderAuto, ProviderIPAPI, ProviderTencent, ProviderNone:
case "":
p = ProviderAuto
default:
logrus.WithField("value", opts.Provider).
Warn("ip_geo_provider 取值无法识别,已回退 auto(可选值:auto / ipapi / tencent / none)")
p = ProviderAuto
}
// 只选了 tencent 却没配 key:无法工作,降级 ipapi 而不是返回空结果。
if p == ProviderTencent && strings.TrimSpace(opts.TencentKey) == "" {
logrus.Warn("ip_geo_provider = tencent 但未配置 ip_geo_tencent_key,已降级为 ipapi")
p = ProviderIPAPI
}
cap := opts.TencentRefineCap
if cap <= 0 {
cap = defaultTencentRefineCap
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: defaultTimeout}
}
return &Resolver{
provider: p,
tencent: strings.TrimSpace(opts.TencentKey),
refineCap: cap,
client: client,
}
}
// Enabled 报告解析器是否真的会去查外部服务。
func (r *Resolver) Enabled() bool { return r != nil && r.provider != ProviderNone }
// Describe 返回人类可读的数据源描述(写进报告,让读者知道坐标有多可信)。
func (r *Resolver) Describe() string {
if r == nil {
return "未启用地理定位"
}
switch r.provider {
case ProviderNone:
return "未启用地理定位(ip_geo_provider = none)"
case ProviderIPAPI:
return "ip-api.com(批量,城市级)"
case ProviderTencent:
return "腾讯位置服务(城市级,GCJ-02)"
default:
if r.tencent != "" {
return "ip-api.com 批量 + 腾讯位置服务精修国内 IP"
}
return "ip-api.com(批量,城市级;未配置腾讯 key,国内 IP 未精修)"
}
}
// Result 一次批量定位的结果。
//
// ⚠️ 必须区分「查不到」与「没查成」:这是本包最容易埋错的地方。
// - Geo[key] 存在 = 查到了;
// - Unresolved[key] = 服务**明确回答**没有这个 IP 的数据(可以短期缓存,
// 免得每份报告都去重试一个注定查不到的地址);
// - 两者都没有 = 请求本身失败了(超时 / 限流 / 网关抖动)。
// 此时**绝不能**写缓存 —— 把网络故障记成「这个 IP 没有位置」,
// 会让接下来三天的报告都缺这一批 IP,而且没有任何线索说明为什么。
type Result struct {
Geo map[string]Geo
Unresolved map[string]bool
// Requests / Failures 本次发出的请求数与失败的请求数(日志用)。
Requests int
Failures int
}
// Lookup 批量解析。keys 是 shared.LimitKey 的产物(IPv4 精确地址 / IPv6 的
// "xxxx::/64")。
//
// 任何失败都只记日志并返回已拿到的部分结果 —— 报告不该因为外部 API 抖动而失败。
func (r *Resolver) Lookup(ctx context.Context, keys []string) Result {
res := Result{Geo: make(map[string]Geo, len(keys)), Unresolved: make(map[string]bool)}
if r == nil || r.provider == ProviderNone || len(keys) == 0 {
return res
}
// 把缓存键还原成可查询的地址:IPv6 键带 "/64" 后缀,查询时用网络地址
// (geo 库对前缀同样能回答,用基址是常见做法)。
//
// ⚠️ 必须按 keys 的原始顺序建 addrs:调用方(报告)是按命中次数从高到低
// 传进来的,腾讯精修只取前 refineCap 个。如果这里遍历 map 取地址,
// Go 的 map 迭代顺序是随机的 —— 每次运行精修到的 IP 都不一样,
// 配额会花在长尾地址上,而「主要从哪个城市来」恰恰取决于最活跃的那批。
queryable := make(map[string]string, len(keys)) // 查询地址 -> 原始键
addrs := make([]string, 0, len(keys))
for _, key := range keys {
addr, ok := queryAddr(key)
if !ok {
continue
}
if _, dup := queryable[addr]; dup {
continue
}
queryable[addr] = key
addrs = append(addrs, addr)
}
if len(addrs) == 0 {
return res
}
if r.provider == ProviderAuto || r.provider == ProviderIPAPI {
got, unresolved, reqs, fails := r.lookupIPAPI(ctx, addrs)
res.Requests += reqs
res.Failures += fails
for addr, g := range got {
if key, ok := queryable[addr]; ok {
res.Geo[key] = g
}
}
for addr := range unresolved {
if key, ok := queryable[addr]; ok {
res.Unresolved[key] = true
}
}
}
// 腾讯精修:auto 只精修国内,tencent 覆盖全部。
if r.tencent != "" && r.provider != ProviderIPAPI {
var refine []string
for _, addr := range addrs {
key := queryable[addr]
if r.provider == ProviderAuto {
g, ok := res.Geo[key]
// 只精修 ip-api 已判为中国的条目:海外 IP 腾讯给不出省市,
// 查了也是白白消耗配额。
if !ok || !isChina(g.CountryCode) {
continue
}
}
refine = append(refine, addr)
}
if len(refine) > r.refineCap {
refine = refine[:r.refineCap]
}
tgot, tunresolved, treqs, tfails := r.lookupTencent(ctx, refine)
res.Requests += treqs
res.Failures += tfails
for addr, g := range tgot {
if key, ok := queryable[addr]; ok {
// 保留 ip-api 给的国家(腾讯对海外 IP 的 nation 常常是空的),
// 用腾讯的省市与坐标覆盖。
prev := res.Geo[key]
if g.Country == "" {
g.Country, g.CountryCode = prev.Country, prev.CountryCode
}
res.Geo[key] = g
// 精修成功即视为已定位,撤掉可能存在的「查不到」标记。
delete(res.Unresolved, key)
}
}
for addr := range tunresolved {
if key, ok := queryable[addr]; ok {
if _, done := res.Geo[key]; !done {
res.Unresolved[key] = true
}
}
}
}
return res
}
// isChina 判断国家码是否为中国。
//
// 只看 CN:港澳台在多数 IP 库里是 HK/MO/TW 单独编码,它们的城市名当前
// 不会进入腾讯精修 —— 不做特判,宁可少精修也不要把地区归属搞错。
func isChina(code string) bool { return strings.EqualFold(code, "CN") }
// queryAddr 把缓存键还原为可查询的地址;不可查询(私网/回环/非法)返回 false。
func queryAddr(key string) (string, bool) {
base := key
if i := strings.IndexByte(base, '/'); i >= 0 {
base = base[:i]
}
ip := net.ParseIP(base)
if ip == nil || !isPublic(ip) {
return "", false
}
return ip.String(), true
}
// isPublic 排除私网 / 回环 / 链路本地 / 唯一本地地址。
// 这些地址查不出位置,发出去也毫无意义(还会浪费配额)。
func isPublic(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
return false
}
// IPv4 映射到 IPv6 的形式(::ffff:1.2.3.4)统一按 v4 处理。
if v4 := ip.To4(); v4 != nil {
return !(v4[0] == 0 || v4[0] == 10 || v4[0] == 127 ||
(v4[0] == 169 && v4[1] == 254) ||
(v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) ||
(v4[0] == 192 && v4[1] == 168) ||
(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127))
}
return true
}
// ---- ip-api.com ----
type ipAPIResult struct {
Query string `json:"query"`
Status string `json:"status"`
Message string `json:"message"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
RegionName string `json:"regionName"`
City string `json:"city"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
ISP string `json:"isp"`
AS string `json:"as"`
}
// lookupIPAPI 分批查询 ip-api.com。
//
// 返回三样东西:
// - got:查到的地址 → Geo;
// - unresolved:服务明确回答「这个地址没有数据」的地址;
// - requests / failures:发出的请求数与失败数。
//
// 失败的批次整批都不进 unresolved —— 区别于「服务说没有这个 IP 的数据」。
//
// 免费版只支持 http(https 要 pro)—— 这是该服务的既定限制,不是笔误。
// 请求体里只有攻击者 IP,不含任何站点数据。
func (r *Resolver) lookupIPAPI(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) {
got := make(map[string]Geo, len(addrs))
unresolved := make(map[string]bool)
requests, failures := 0, 0
for start := 0; start < len(addrs); start += ipAPIBatchSize {
end := start + ipAPIBatchSize
if end > len(addrs) {
end = len(addrs)
}
batch := addrs[start:end]
res, err := r.ipAPIBatchWithRetry(ctx, batch)
requests++
if err != nil {
failures++
logrus.WithError(err).WithField("count", len(batch)).
Warn("ip-api 批量查询失败:这批 IP 本次不定位,且不会写入缓存(下次运行会重试)")
continue
}
for addr, g := range res.geo {
got[addr] = g
}
for addr := range res.unresolved {
unresolved[addr] = true
}
}
return got, unresolved, requests, failures
}
// batchOutcome 一个批次的解析结果。
type batchOutcome struct {
geo map[string]Geo
unresolved map[string]bool
}
// ipAPIBatchWithRetry 带一次重试的批量请求。
//
// 为什么要重试:ip-api 免费版对同一来源有节流,偶发慢响应/网关 5xx 很常见。
// 不重试的话,一次抖动就会让整份报告缺掉最多 100 个 IP(而报告一周只出一次,
// 没有「下个小时再看」的机会)。重试一次的成本是 3 秒等待,值得。
func (r *Resolver) ipAPIBatchWithRetry(ctx context.Context, addrs []string) (*batchOutcome, error) {
var lastErr error
for attempt := 1; attempt <= ipAPIAttempts; attempt++ {
res, err := r.ipAPIBatch(ctx, addrs)
if err == nil {
return res, nil
}
lastErr = err
if attempt < ipAPIAttempts {
logrus.WithError(err).WithField("attempt", attempt).Debug("ip-api 批量查询失败,准备重试")
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(ipAPIRetryDelay):
}
}
}
return nil, lastErr
}
func (r *Resolver) ipAPIBatch(ctx context.Context, addrs []string) (*batchOutcome, error) {
// 按序返回:ip-api 的批量接口保证结果顺序与输入一致,用下标对应最稳
// (不要拿返回的 query 字段当键 —— 它对 IPv6 会做规范化,和输入不逐字相同)。
body, err := json.Marshal(addrs)
if err != nil {
return nil, errors.Wrap(err, "marshal batch")
}
if err := r.waitTurn(); err != nil {
return nil, err
}
url := "http://ip-api.com/batch?fields=status,message,query,country,countryCode,regionName,city,lat,lon,isp,as"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, errors.Wrap(err, "build request")
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return nil, errors.Errorf("ip-api status %d: %s", resp.StatusCode, string(snippet))
}
var results []ipAPIResult
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return nil, errors.Wrap(err, "decode")
}
out := &batchOutcome{geo: make(map[string]Geo, len(results)), unresolved: make(map[string]bool)}
for i, item := range results {
if i >= len(addrs) {
break
}
if item.Status != "success" {
// 服务明确回答了这个地址没有数据(保留地址 / 未收录)。
// 只有这种情况才允许进 unresolved(也就是允许被缓存成「查不到」)。
out.unresolved[addrs[i]] = true
continue
}
out.geo[addrs[i]] = Geo{
Country: item.Country,
CountryCode: item.CountryCode,
Region: item.RegionName,
City: item.City,
ISP: item.ISP,
ASN: normalizeASN(item.AS),
Lat: item.Lat,
Lon: item.Lon,
// ip-api 对国家级结果也会给一个中心点坐标(精度差但可用),
// 只要不是 (0,0) 就认为有坐标。
HasCoords: item.Lat != 0 || item.Lon != 0,
Source: ProviderIPAPI,
}
}
return out, nil
}
// waitTurn 串行地保证两次 ip-api 请求间隔不小于 ipAPIMinInterval。
func (r *Resolver) waitTurn() error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.lastReq.IsZero() {
if wait := ipAPIMinInterval - time.Since(r.lastReq); wait > 0 {
time.Sleep(wait)
}
}
r.lastReq = time.Now()
return nil
}
// normalizeASN 把 ip-api 的 "AS15169 Google LLC" 拆成 "AS15169"。
func normalizeASN(as string) string {
as = strings.TrimSpace(as)
if as == "" {
return ""
}
if i := strings.IndexByte(as, ' '); i > 0 {
return as[:i]
}
return as
}
// ---- 腾讯位置服务 ----
type tencentResult struct {
Status int `json:"status"`
Message string `json:"message"`
Result struct {
IP string `json:"ip"`
Location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"location"`
AdInfo struct {
Nation string `json:"nation"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
} `json:"ad_info"`
} `json:"result"`
}
// tencentOutcome 单个地址的查询结果分类。
type tencentOutcome int
const (
// tencentFound 查到并给出了行政区划。
tencentFound tencentOutcome = iota
// tencentNoData 服务明确回答没有这个地址的数据(status != 0)。
tencentNoData
// tencentFailed 网络 / 解析失败 —— 与「没有数据」不是一回事,不能缓存。
tencentFailed
)
// lookupTencent 逐个查询腾讯 IP 定位(并发度 2,别把免费配额打爆)。
func (r *Resolver) lookupTencent(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) {
got := make(map[string]Geo, len(addrs))
unresolved := make(map[string]bool)
if len(addrs) == 0 || r.tencent == "" {
return got, unresolved, 0, 0
}
const workers = 2
var (
mu sync.Mutex
wg sync.WaitGroup
sem = make(chan struct{}, workers)
failures int
)
for _, addr := range addrs {
wg.Add(1)
go func(addr string) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
g, oc := r.tencentOne(ctx, addr)
mu.Lock()
defer mu.Unlock()
switch oc {
case tencentFound:
got[addr] = g
case tencentNoData:
unresolved[addr] = true
default:
failures++
}
}(addr)
}
wg.Wait()
return got, unresolved, len(addrs), failures
}
func (r *Resolver) tencentOne(ctx context.Context, addr string) (Geo, tencentOutcome) {
url := fmt.Sprintf("https://apis.map.qq.com/ws/location/v1/ip?ip=%s&key=%s", addr, r.tencent)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Geo{}, tencentFailed
}
resp, err := r.client.Do(req)
if err != nil {
return Geo{}, tencentFailed
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Geo{}, tencentFailed
}
var tr tencentResult
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return Geo{}, tencentFailed
}
// status != 0 表示查询失败(配额用尽、key 无效、IP 无数据等)。
// 只记 Debug:几千个 IP 逐个刷警告会把日志淹掉。
if tr.Status != 0 {
logrus.WithFields(logrus.Fields{"status": tr.Status, "message": tr.Message}).
Debug("腾讯 IP 定位返回非 0 状态")
// 配额用尽 / key 无效属于「配置或额度问题」,不是「这个 IP 没数据」。
// 把它们混为一谈会把整批 IP 缓存成「查不到」,此后三天都不再精修。
return Geo{}, tencentFailed
}
ad := tr.Result.AdInfo
city := ad.City
if city == "" {
city = ad.District
}
if ad.Province == "" && city == "" {
// 状态成功但没有任何行政区划信息:这才是「这个地址没数据」。
return Geo{}, tencentNoData
}
return Geo{
Country: ad.Nation,
CountryCode: tencentCountryCode(ad.Nation),
Region: ad.Province,
City: city,
Lat: tr.Result.Location.Lat,
Lon: tr.Result.Location.Lng,
HasCoords: tr.Result.Location.Lat != 0 || tr.Result.Location.Lng != 0,
Source: ProviderTencent,
}, tencentFound
}
// tencentCountryCode 从腾讯返回的 nation 推国家码。
//
// 不能无脑写 "CN":provider = tencent 时(不先过 ip-api)所有地址都会走这条路,
// 海外 IP 的 nation 会是「美国」之类,硬编码 CN 会把它们统计成中国来源 ——
// 而「攻击来自哪些国家」正是这份报告的核心结论之一。
//
// 只认「中国」这个精确值:港澳台在腾讯的 nation 里是「中国香港」等,
// 与 ip-api 的 HK/MO/TW 编码对不上,留空交给上层按国家名兜底更安全。
func tencentCountryCode(nation string) string {
if strings.TrimSpace(nation) == "中国" {
return "CN"
}
return ""
}
+500
View File
@@ -0,0 +1,500 @@
// 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 geoip
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// rewriteTransport 把真实域名的请求改投到本地测试服务器。
//
// 用改投而不是给生产代码加一个 baseURL 字段:生产代码里不该为了测试多出
// 「可以被指向别处」的开关,那类开关一旦被配置错就是把攻击者 IP 发到未知主机。
type rewriteTransport struct {
target *url.URL
}
func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
next := req.Clone(req.Context())
next.URL.Scheme = t.target.Scheme
next.URL.Host = t.target.Host
return http.DefaultTransport.RoundTrip(next)
}
// newTestClient 返回一个把所有出站请求改投到 srv 的 client。
func newTestClient(t *testing.T, srv *httptest.Server) *http.Client {
t.Helper()
u, err := url.Parse(srv.URL)
require.NoError(t, err)
return &http.Client{Transport: &rewriteTransport{target: u}}
}
// ipAPIServer 起一个假的 ip-api 批量接口。
//
// respond 收到本批请求的地址列表,返回对应的 ipAPIResult 切片;
// status 非 0 时直接返回该 HTTP 状态码(用于模拟网关故障)。
func ipAPIServer(t *testing.T, respond func(addrs []string) ([]ipAPIResult, int)) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/batch" {
http.NotFound(w, req)
return
}
if req.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
atomic.AddInt32(&calls, 1)
var addrs []string
if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
results, code := respond(addrs)
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(results)
}))
t.Cleanup(srv.Close)
return srv, &calls
}
// okIP 构造一条成功的 ip-api 结果。
func okIP(addr, country, code, city string, lat, lon float64, isp, as string) ipAPIResult {
return ipAPIResult{
Query: addr, Status: "success", Country: country, CountryCode: code,
City: city, Lat: lat, Lon: lon, ISP: isp, AS: as,
}
}
// TestLookupSuccess 三态里的「查到了」与「服务明确说没有」。
func TestLookupSuccess(t *testing.T) {
srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{
okIP(addrs[0], "德国", "DE", "Berlin", 52.52, 13.4, "Hetzner", "AS24940 Hetzner Online GmbH"),
{Query: addrs[1], Status: "fail", Message: "reserved range"},
}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"5.9.1.1", "203.0.113.7"})
require.Equal(t, int32(1), atomic.LoadInt32(calls))
require.Equal(t, 1, res.Requests)
require.Equal(t, 0, res.Failures)
g, ok := res.Geo["5.9.1.1"]
require.True(t, ok)
assert.Equal(t, "德国", g.Country)
assert.Equal(t, "DE", g.CountryCode)
assert.Equal(t, "Berlin", g.City)
assert.Equal(t, "AS24940", g.ASN, "AS 字段必须截成纯 ASN,否则国家统计里全是长串")
assert.True(t, g.HasCoords)
assert.Equal(t, ProviderIPAPI, g.Source)
// status != success 才是「服务明确说没有」,才可以被缓存成查不到
assert.True(t, res.Unresolved["203.0.113.7"])
assert.NotContains(t, res.Geo, "203.0.113.7")
}
// TestLookupNetworkFailure 三态里的「没查成」:绝不能进 Unresolved。
//
// 这条断言是本包最重要的回归保护:一旦失败也进 Unresolved,上层就会把它
// 写进缓存(3 天),此后每份报告的定位覆盖率都会莫名其妙变成 0。
func TestLookupNetworkFailure(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusBadGateway
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"8.8.4.4", "1.0.0.1"})
assert.Equal(t, int32(ipAPIAttempts), atomic.LoadInt32(calls), "必须重试,但只重试一次")
assert.Equal(t, 1, res.Requests)
assert.Equal(t, 1, res.Failures)
assert.Empty(t, res.Geo)
assert.Empty(t, res.Unresolved, "网络失败不能记成「这个 IP 没有位置」")
}
// TestLookupRetrySucceeds 第一次抖动、第二次正常:不该丢数据。
func TestLookupRetrySucceeds(t *testing.T) {
var attempt int32
srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
if atomic.AddInt32(&attempt, 1) == 1 {
return nil, http.StatusGatewayTimeout
}
return []ipAPIResult{okIP(addrs[0], "日本", "JP", "Tokyo", 35.68, 139.69, "", "")}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"133.242.0.1"})
require.Equal(t, 0, res.Failures)
assert.Equal(t, "日本", res.Geo["133.242.0.1"].Country)
}
// TestLookupBatches 超过 100 个地址必须分批,而不是一次塞进去被拒。
func TestLookupBatches(t *testing.T) {
srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
assert.LessOrEqual(t, len(addrs), ipAPIBatchSize, "单批不能超过 ip-api 的硬上限")
out := make([]ipAPIResult, 0, len(addrs))
for _, a := range addrs {
out = append(out, okIP(a, "美国", "US", "Ashburn", 39.0, -77.5, "", ""))
}
return out, 0
})
addrs := make([]string, 0, 150)
for i := 0; i < 150; i++ {
addrs = append(addrs, fmt.Sprintf("198.51.%d.%d", i/256, i%256))
}
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), addrs)
assert.Equal(t, int32(2), atomic.LoadInt32(calls))
assert.Equal(t, 2, res.Requests)
assert.Len(t, res.Geo, 150)
}
// TestLookupSkipsPrivate 私网 / 回环 / CGNAT 一律不外发:既不泄露内网结构,
// 也省下配额(这类地址查了也是空的)。
func TestLookupSkipsPrivate(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{
"10.0.0.1", "192.168.1.1", "172.16.0.9", "127.0.0.1",
"100.64.3.4", "169.254.1.1", "::1", "fe80::1", "fd00::1",
})
assert.Equal(t, int32(0), atomic.LoadInt32(calls), "不可查询的地址不该产生任何请求")
assert.Equal(t, 0, res.Requests)
assert.Empty(t, res.Geo)
assert.Empty(t, res.Unresolved)
}
// TestLookupNone provider = none:一个字节都不外发。
func TestLookupNone(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderNone, HTTPClient: newTestClient(t, srv)})
assert.False(t, r.Enabled())
res := r.Lookup(context.Background(), []string{"1.2.3.4"})
assert.Equal(t, int32(0), atomic.LoadInt32(calls))
assert.Empty(t, res.Geo)
}
// TestLookupEmptyKeys 空输入不该发请求。
func TestLookupEmptyKeys(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
assert.Empty(t, r.Lookup(context.Background(), nil).Geo)
assert.Equal(t, int32(0), atomic.LoadInt32(calls))
}
// TestLookupIPv6Prefix IPv6 的 "/64" 缓存键要还原成基址去查。
func TestLookupIPv6Prefix(t *testing.T) {
srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
require.Len(t, addrs, 1)
assert.Equal(t, "2001:4860:4860::", addrs[0], "带 /64 后缀的键必须剥掉后缀再查")
return []ipAPIResult{okIP(addrs[0], "美国", "US", "Mountain View", 37.4, -122.0, "Google", "AS15169 Google LLC")}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"2001:4860:4860::/64"})
g, ok := res.Geo["2001:4860:4860::/64"]
require.True(t, ok, "结果键必须仍是原来的缓存键,不能变成查询用的地址")
assert.Equal(t, "US", g.CountryCode)
assert.Equal(t, "AS15169", g.ASN)
}
// tencentServer 起一个假的腾讯 IP 定位接口。
func tencentServer(t *testing.T, respond func(ip string) (tencentResult, int)) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/ws/location/v1/ip" {
http.NotFound(w, req)
return
}
atomic.AddInt32(&calls, 1)
tr, code := respond(req.URL.Query().Get("ip"))
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tr)
}))
t.Cleanup(srv.Close)
return srv, &calls
}
// combinedServer 同时提供两条通道的测试服务器(auto 模式要两条都通)。
func combinedServer(t *testing.T, ipapi func([]string) ([]ipAPIResult, int), tencent func(ip string) (tencentResult, int)) (*httptest.Server, *int32, *int32) {
t.Helper()
var ipCalls, txCalls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/batch":
atomic.AddInt32(&ipCalls, 1)
var addrs []string
if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
results, code := ipapi(addrs)
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(results)
case "/ws/location/v1/ip":
atomic.AddInt32(&txCalls, 1)
tr, code := tencent(req.URL.Query().Get("ip"))
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tr)
default:
http.NotFound(w, req)
}
}))
t.Cleanup(srv.Close)
return srv, &ipCalls, &txCalls
}
func tencentOK(nation, province, city string, lat, lon float64) tencentResult {
var tr tencentResult
tr.Status = 0
tr.Result.Location.Lat, tr.Result.Location.Lng = lat, lon
tr.Result.AdInfo.Nation = nation
tr.Result.AdInfo.Province = province
tr.Result.AdInfo.City = city
return tr
}
// TestLookupAutoOnlyRefinesChina auto 模式只对国内 IP 调腾讯:
// 海外 IP 腾讯给不出省市,查了也是白花配额。
func TestLookupAutoOnlyRefinesChina(t *testing.T) {
srv, _, txCalls := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{
okIP(addrs[0], "中国", "CN", "", 0, 0, "", ""),
okIP(addrs[1], "美国", "US", "Ashburn", 39.0, -77.5, "", ""),
}, 0
},
func(ip string) (tencentResult, int) {
return tencentOK("中国", "北京市", "北京市", 39.9087, 116.3975), 0
})
r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114", "45.32.0.1"})
assert.Equal(t, int32(1), atomic.LoadInt32(txCalls), "只该精修 1 个国内 IP")
cn := res.Geo["114.114.114.114"]
assert.Equal(t, "北京市", cn.Region)
assert.Equal(t, "北京市", cn.City)
assert.Equal(t, "CN", cn.CountryCode)
assert.InDelta(t, 39.9087, cn.Lat, 1e-9)
assert.Equal(t, ProviderTencent, cn.Source)
us := res.Geo["45.32.0.1"]
assert.Equal(t, "US", us.CountryCode)
assert.Equal(t, "Ashburn", us.City, "海外 IP 应保留 ip-api 的结果")
assert.Equal(t, ProviderIPAPI, us.Source)
}
// TestLookupTencentRefineCap 精修数量必须有上限,否则周报会跑十几分钟。
//
// 顺带守住一个容易踩的坑:精修必须按调用方给的顺序取前 N 个。报告是按命中
// 次数从高到低传进来的,若这里按 map 遍历顺序取,每次运行精修到的 IP 都不一样,
// 配额会花在长尾地址上,「主要从哪个城市来」这个问题就答不准了。
func TestLookupTencentRefineCap(t *testing.T) {
var mu sync.Mutex
var refined []string
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
out := make([]ipAPIResult, 0, len(addrs))
for _, a := range addrs {
out = append(out, okIP(a, "中国", "CN", "", 0, 0, "", ""))
}
return out, 0
},
func(ip string) (tencentResult, int) {
mu.Lock()
refined = append(refined, ip)
mu.Unlock()
return tencentOK("中国", "广东省", "深圳市", 22.5, 114.0), 0
})
addrs := []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4", "5.5.5.5"}
r := New(Options{
Provider: ProviderAuto, TencentKey: "k", TencentRefineCap: 2,
HTTPClient: newTestClient(t, srv),
})
res := r.Lookup(context.Background(), addrs)
require.Len(t, refined, 2)
assert.ElementsMatch(t, []string{"1.1.1.1", "2.2.2.2"}, refined,
"必须精修前 2 个(最活跃的),而不是随机的 2 个")
// 精修到的用腾讯结果
assert.Equal(t, ProviderTencent, res.Geo["1.1.1.1"].Source)
// 没精修到的仍保留 ip-api 的国家
assert.Equal(t, "CN", res.Geo["5.5.5.5"].CountryCode)
assert.Equal(t, ProviderIPAPI, res.Geo["5.5.5.5"].Source)
}
// TestLookupTencentFailureNotCached 腾讯配额用尽 / key 无效属于「没查成」,
// 不能把整批 IP 记成「查不到」。ip-api 已经给出的结果也不能被抹掉。
func TestLookupTencentFailureNotCached(t *testing.T) {
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0
},
func(string) (tencentResult, int) {
var tr tencentResult
tr.Status = 311
tr.Message = "key格式错误"
return tr, 0
})
r := New(Options{Provider: ProviderAuto, TencentKey: "bad", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114"})
assert.Equal(t, 1, res.Failures)
assert.Empty(t, res.Unresolved, "配额/key 问题不是「这个 IP 没数据」")
assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode, "ip-api 的结果不能被腾讯的失败抹掉")
}
// TestLookupTencentNoData 状态成功但没有任何行政区划 = 真的没数据。
// 但若 ip-api 已经定位到了,也不能反过来把这条记成「查不到」。
func TestLookupTencentNoData(t *testing.T) {
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0
},
func(string) (tencentResult, int) { return tencentOK("中国", "", "", 0, 0), 0 })
r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114"})
assert.Equal(t, 0, res.Failures)
assert.NotContains(t, res.Unresolved, "114.114.114.114", "已定位的 IP 不该被降级成查不到")
assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode)
}
// TestTencentOnly 只用腾讯(provider = tencent):不经 ip-api,全部走腾讯。
func TestTencentOnly(t *testing.T) {
srv, _, txCalls := combinedServer(t,
func([]string) ([]ipAPIResult, int) {
t.Error("provider = tencent 时不该请求 ip-api")
return nil, 0
},
func(string) (tencentResult, int) {
return tencentOK("中国", "浙江省", "杭州市", 30.27, 120.15), 0
})
r := New(Options{Provider: ProviderTencent, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"115.236.1.1"})
assert.Equal(t, int32(1), atomic.LoadInt32(txCalls))
assert.Equal(t, "杭州市", res.Geo["115.236.1.1"].City)
assert.Equal(t, "CN", res.Geo["115.236.1.1"].CountryCode)
}
// TestNewFallbacks 配置错误不能表现为「静默不工作」。
func TestNewFallbacks(t *testing.T) {
// 只选 tencent 却没 key → 降级 ipapi
r := New(Options{Provider: ProviderTencent})
assert.Equal(t, ProviderIPAPI, r.provider)
assert.True(t, r.Enabled())
// 空值 → auto
assert.Equal(t, ProviderAuto, New(Options{}).provider)
// 不认识的值 → auto
assert.Equal(t, ProviderAuto, New(Options{Provider: "maxmind"}).provider)
// none 不工作
assert.False(t, New(Options{Provider: ProviderNone}).Enabled())
// refineCap 缺省
assert.Equal(t, defaultTencentRefineCap, New(Options{}).refineCap)
}
// TestDescribe 描述会写进报告,读者靠它判断坐标可信度。
func TestDescribe(t *testing.T) {
assert.Contains(t, New(Options{Provider: ProviderAuto, TencentKey: "k"}).Describe(), "腾讯")
assert.Contains(t, New(Options{Provider: ProviderAuto}).Describe(), "未配置腾讯 key")
assert.Contains(t, New(Options{Provider: ProviderIPAPI}).Describe(), "ip-api")
assert.Contains(t, New(Options{Provider: ProviderNone}).Describe(), "未启用")
}
// TestQueryAddr 缓存键还原。
func TestQueryAddr(t *testing.T) {
cases := []struct {
key string
want string
ok bool
}{
{"8.8.8.8", "8.8.8.8", true},
{"2001:4860:4860::/64", "2001:4860:4860::", true},
{"2001:4860:4860::8888", "2001:4860:4860::8888", true},
{"10.1.2.3", "", false},
{"::ffff:8.8.8.8", "8.8.8.8", true},
{"not-an-ip", "", false},
{"", "", false},
}
for _, c := range cases {
got, ok := queryAddr(c.key)
assert.Equal(t, c.ok, ok, "key = %q", c.key)
assert.Equal(t, c.want, got, "key = %q", c.key)
}
}
// TestNormalizeASN ASN 只留编号。
func TestNormalizeASN(t *testing.T) {
assert.Equal(t, "AS15169", normalizeASN("AS15169 Google LLC"))
assert.Equal(t, "AS4134", normalizeASN(" AS4134 "))
assert.Equal(t, "AS4134", normalizeASN("AS4134"))
assert.Equal(t, "", normalizeASN(""))
}
// TestTencentCountryCode 不能把海外 IP 算成中国来源。
func TestTencentCountryCode(t *testing.T) {
assert.Equal(t, "CN", tencentCountryCode("中国"))
assert.Equal(t, "", tencentCountryCode("中国香港"))
assert.Equal(t, "", tencentCountryCode("美国"))
assert.Equal(t, "", tencentCountryCode(""))
}
+349
View File
@@ -0,0 +1,349 @@
// 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 report
import (
"net"
"sort"
"strings"
"time"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
"github.com/tamakyi/TamaBox/internal/shared"
)
// 未知标签。用「未知」而不是空串:报告里空单元格会被读成「没有」,
// 而这里的真实含义是「查不到」,两件事不能混。
const (
unknownCountry = "未知"
unknownCode = "--"
)
// SourceRow 一行来源明细。
type SourceRow struct {
IP string
Hits int64
Banned int64
Creds int64
Country string
CountryCode string
Region string
City string
ISP string
ASN string
Provider string // cdncheck 标签(云厂商 / CDN / WAF / 普通网络)
Lat float64
Lon float64
HasCoords bool
GeoSource string
}
// CountryRow 国家级聚合。
type CountryRow struct {
Code string
Name string
Hits int64
Sources int
// Share 命中占比(0-1)。
Share float64
}
// CityRow 城市级聚合(带坐标,供地图使用)。
type CityRow struct {
Country string
CountryCode string
Region string
City string
Hits int64
Sources int
Lat float64
Lon float64
}
// PathRow 被探测路径聚合。
type PathRow struct {
Path string
Hits int64
}
// DayRow 每日命中数。
type DayRow struct {
Day time.Time
Hits int64
}
// Summary 一份完整的汇总。
type Summary struct {
Period Period
GeneratedAt time.Time
// 总量指标
TotalHits int64
PrevHits int64
UniqueSources int
UniqueCountry int
BannedHits int64
CredHits int64
// UnlocatedHits 是「查不到地理位置」的命中数。必须显式报出来 ——
// 一个覆盖率 60% 的地图看起来与 100% 的一模一样,读图的人会误判。
UnlocatedHits int64
// CloudSources 命中来自云厂商/CDN/WAF 的独立来源数(cdncheck 判定)。
// 与家宽肉鸡区分开:前者封 IP 基本没用(换一台机器就行),
// 后者封了会打在真实用户身上(同一个出口可能共享给整栋楼)。
CloudSources int
Countries []CountryRow
Cities []CityRow
Sources []SourceRow
Paths []PathRow
Days []DayRow
MaxDay int64
ResolverDesc string
// Empty 表示窗口内一次命中都没有 —— 报告仍然要发(「这周没人打」是个结论,
// 不是失败),但排版会换成一句明确的结论而不是一堆空表格。
Empty bool
}
// Input Build 的输入。
type Input struct {
Period Period
Sources []*db.HoneypotSourceAgg
Paths []*db.HoneypotPathStat
Timestamps []time.Time
Geo map[string]geoip.Geo // 键为 shared.LimitKey 的产物
PrevHits int64
ResolverDesc string
Now time.Time
}
// maxSourceRows 报告里最多列多少条来源明细。
//
// 不是技术限制而是阅读限制:一份列了两千行的报告没人会看完,
// 而尾部每行 1 次命中的长尾对判断趋势没有价值。
const maxSourceRows = 200
// Build 把原始聚合结果整理成可渲染的 Summary。
func Build(in Input) *Summary {
now := in.Now
if now.IsZero() {
now = time.Now()
}
sum := &Summary{
Period: in.Period,
GeneratedAt: now,
PrevHits: in.PrevHits,
ResolverDesc: in.ResolverDesc,
}
// ---- 1. 按 LimitKey 合并来源 ----
// 同一台机器在 /64 内轮换 IPv6 地址是很常见的规避手法,按精确地址统计
// 会得到几十条各 1 次的记录,看不出「其实是一个人」。
type merged struct {
row SourceRow
maxHits int64
}
byKey := make(map[string]*merged, len(in.Sources))
for _, s := range in.Sources {
key := shared.LimitKey(s.IP)
m, ok := byKey[key]
if !ok {
byKey[key] = &merged{
row: SourceRow{
IP: s.IP,
Hits: s.Hits,
Banned: s.Banned,
Creds: s.Creds,
Provider: s.Provider,
},
maxHits: s.Hits,
}
continue
}
m.row.Hits += s.Hits
m.row.Banned += s.Banned
m.row.Creds += s.Creds
if m.row.Provider == "" {
m.row.Provider = s.Provider
}
// 代表地址取命中最多的那个精确 IP:报告里展示的应该是最活跃的那一台。
if s.Hits > m.maxHits {
m.maxHits = s.Hits
m.row.IP = s.IP
}
}
// ---- 2. 挂上地理信息 ----
sources := make([]SourceRow, 0, len(byKey))
for key, m := range byKey {
row := m.row
if g, ok := in.Geo[key]; ok {
row.Country = g.Country
row.CountryCode = strings.ToUpper(g.CountryCode)
row.Region = g.Region
row.City = g.City
row.ISP = g.ISP
row.ASN = g.ASN
row.Lat, row.Lon = g.Lat, g.Lon
row.HasCoords = g.HasCoords
row.GeoSource = g.Source
}
if row.Country == "" {
row.Country = unknownCountry
}
if row.CountryCode == "" {
row.CountryCode = unknownCode
}
// 展示名在聚合层统一转换,下游(HTML / 邮件 / 地图提示)就都一致了。
// 港澳台在这里变成「中国香港 / 中国澳门 / 中国台湾」(理由见 countryName)。
row.Country = countryName(row.CountryCode, row.Country)
if !row.HasCoords {
sum.UnlocatedHits += row.Hits
}
if isCloudProvider(row.Provider) {
sum.CloudSources++
}
sum.TotalHits += row.Hits
sum.BannedHits += row.Banned
sum.CredHits += row.Creds
sources = append(sources, row)
}
sum.UniqueSources = len(sources)
sum.Empty = sum.TotalHits == 0
sort.Slice(sources, func(i, j int) bool {
if sources[i].Hits != sources[j].Hits {
return sources[i].Hits > sources[j].Hits
}
return sources[i].IP < sources[j].IP // 稳定排序,避免每次生成顺序抖动
})
if len(sources) > maxSourceRows {
sum.Sources = sources[:maxSourceRows]
} else {
sum.Sources = sources
}
// ---- 3. 国家聚合 ----
type cAgg struct {
name string
hits int64
sources int
}
countries := make(map[string]*cAgg)
for _, s := range sources {
c, ok := countries[s.CountryCode]
if !ok {
c = &cAgg{name: s.Country}
countries[s.CountryCode] = c
}
c.hits += s.Hits
c.sources++
}
for code, c := range countries {
share := 0.0
if sum.TotalHits > 0 {
share = float64(c.hits) / float64(sum.TotalHits)
}
sum.Countries = append(sum.Countries, CountryRow{
Code: code, Name: countryName(code, c.name), Hits: c.hits, Sources: c.sources, Share: share,
})
}
sum.UniqueCountry = len(sum.Countries)
sort.Slice(sum.Countries, func(i, j int) bool {
if sum.Countries[i].Hits != sum.Countries[j].Hits {
return sum.Countries[i].Hits > sum.Countries[j].Hits
}
return sum.Countries[i].Code < sum.Countries[j].Code
})
// ---- 4. 城市聚合(地图用,必须有坐标) ----
type cityAgg struct {
row CityRow
sources int
}
cities := make(map[string]*cityAgg)
for _, s := range sources {
if !s.HasCoords {
continue
}
// 同城不同区合并:地图上同一坐标画十几个重叠气泡没有意义。
key := s.CountryCode + "|" + s.Region + "|" + s.City
c, ok := cities[key]
if !ok {
cities[key] = &cityAgg{row: CityRow{
Country: s.Country, CountryCode: s.CountryCode,
Region: s.Region, City: s.City,
Hits: s.Hits, Sources: 1,
Lat: s.Lat, Lon: s.Lon,
}}
continue
}
c.row.Hits += s.Hits
c.row.Sources++
}
for _, c := range cities {
sum.Cities = append(sum.Cities, c.row)
}
sort.Slice(sum.Cities, func(i, j int) bool {
if sum.Cities[i].Hits != sum.Cities[j].Hits {
return sum.Cities[i].Hits > sum.Cities[j].Hits
}
return sum.Cities[i].City < sum.Cities[j].City
})
// ---- 5. 路径 ----
for _, p := range in.Paths {
sum.Paths = append(sum.Paths, PathRow{Path: p.Path, Hits: p.Count})
}
// ---- 6. 每日趋势 ----
// 先按本地日期把周期内每一天都填成 0,再累加 —— 没有命中的日子必须
// 显示为 0 而不是「不存在」,否则趋势图会把断档画成连续的。
dayIndex := make(map[string]int)
for d := in.Period.Start; d.Before(in.Period.End); d = d.AddDate(0, 0, 1) {
dayIndex[d.Format("2006-01-02")] = len(sum.Days)
sum.Days = append(sum.Days, DayRow{Day: d})
}
for _, ts := range in.Timestamps {
key := ts.In(in.Period.Start.Location()).Format("2006-01-02")
if i, ok := dayIndex[key]; ok {
sum.Days[i].Hits++
}
}
for _, d := range sum.Days {
if d.Hits > sum.MaxDay {
sum.MaxDay = d.Hits
}
}
return sum
}
// isCloudProvider 判断 cdncheck 的标签是否指出了一个云厂商/CDN/WAF。
// ipinfo.Lookup 未命中时返回「未识别(普通网络)」,失败时返回「识别失败…」——
// 这两种都算「非云」。
func isCloudProvider(label string) bool {
if label == "" {
return false
}
return !strings.HasPrefix(label, "未识别") && !strings.HasPrefix(label, "识别失败")
}
// Location 返回「国家 · 省 · 市」的展示串,缺失部分自动省略。
func (s SourceRow) Location() string {
parts := make([]string, 0, 3)
for _, v := range []string{s.Country, s.Region, s.City} {
if v != "" && v != unknownCountry {
parts = append(parts, v)
}
}
if len(parts) == 0 {
return "位置未知"
}
return strings.Join(parts, " · ")
}
// IsIPLiteral 报告该来源是否为可直接引用的 IP 字面量(用于 abuse 投诉模板)。
func (s SourceRow) IsIPLiteral() bool { return net.ParseIP(s.IP) != nil }
+154
View File
@@ -0,0 +1,154 @@
// 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 report
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
)
// TestBuildMergesIPv6By64 覆盖「同一台机器在 /64 内轮换地址」的合并语义。
//
// 这是报告最容易出错的地方:按精确地址统计会把一台机器算成 8 个来源,
// 于是「独立来源数」虚高、地理聚合里同一个城市出现多次。
func TestBuildMergesIPv6By64(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sources := []*db.HoneypotSourceAgg{
{IP: "2001:db8:1:2::1", Hits: 10, Banned: 10, Creds: 2},
{IP: "2001:db8:1:2::2", Hits: 5, Banned: 5, Creds: 1},
{IP: "2001:db8:1:3::9", Hits: 1}, // 不同 /64,不能合并
{IP: "203.0.113.7", Hits: 3, Banned: 3, Creds: 3},
}
geo := map[string]geoip.Geo{
"2001:db8:1:2::/64": {Country: "中国", CountryCode: "cn", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true},
"2001:db8:1:3::/64": {Country: "中国", CountryCode: "CN", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true},
"203.0.113.7": {Country: "美国", CountryCode: "US", Region: "California", City: "Fremont", Lat: 37.5, Lon: -121.9, HasCoords: true},
}
sum := Build(Input{
Period: period, Sources: sources, Geo: geo,
Now: start.AddDate(0, 0, 7),
})
require.Equal(t, int64(19), sum.TotalHits)
// 四条精确地址压成三个来源(两个 /64 + 一个 IPv4):
// 若不合并会是 4,「同一个 C 段算成两台机器」的错觉就来自这里。
require.Equal(t, 3, sum.UniqueSources)
// 合并后的 /64 命中数应为 15,且排第一;展示用的 IP 取该 /64 里最活跃的
// 那一台精确地址(::1 命中 10 次 > ::2 的 5 次)—— 报告里要能直接复制
// 这个地址去查日志,所以不展示折后的前缀。
require.Equal(t, "2001:db8:1:2::1", sum.Sources[0].IP)
require.Equal(t, int64(15), sum.Sources[0].Hits)
require.Equal(t, int64(3), sum.Sources[0].Creds)
// 两个 /64 都在北京,城市聚合应当合成一个 16 次的气泡。
require.Len(t, sum.Cities, 2)
require.Equal(t, "北京", sum.Cities[0].City)
require.Equal(t, int64(16), sum.Cities[0].Hits)
// 国家码必须统一成大写(ip-api 与腾讯的大小写不一致,不统一会出现
// 同一个国家被拆成 CN / cn 两行)。
require.Len(t, sum.Countries, 2)
require.Equal(t, "CN", sum.Countries[0].Code)
require.Equal(t, "US", sum.Countries[1].Code)
require.Equal(t, int64(16), sum.Countries[0].Hits)
}
// TestBuildUnlocated 覆盖「查不到位置」的显式统计。
//
// 覆盖率必须是报告里的显式指标:一张 60% 覆盖的地图和 100% 的看起来一样。
func TestBuildUnlocated(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sum := Build(Input{
Period: period,
Sources: []*db.HoneypotSourceAgg{
{IP: "198.51.100.1", Hits: 7},
{IP: "203.0.113.9", Hits: 3},
},
Geo: map[string]geoip.Geo{
"203.0.113.9": {Country: "日本", CountryCode: "JP", Lat: 35.6, Lon: 139.7, HasCoords: true},
},
Now: start,
})
require.Equal(t, int64(10), sum.TotalHits)
require.Equal(t, int64(7), sum.UnlocatedHits)
// 未定位的那条没有坐标,不能进城市聚合(否则会画到 0,0)。
require.Len(t, sum.Cities, 1)
require.Equal(t, "日本", sum.Cities[0].Country)
// 未定位的来源国家名必须是「未知」而不是空串
require.Equal(t, unknownCountry, sum.Sources[0].Country)
}
// TestBuildCloudSources 覆盖「云主机 vs 肉鸡」的区分。
func TestBuildCloudSources(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sum := Build(Input{
Period: period,
Sources: []*db.HoneypotSourceAgg{
{IP: "203.0.113.1", Hits: 1, Provider: "微软 Azure(云厂商)"},
{IP: "203.0.113.2", Hits: 1, Provider: "未识别(普通网络)"},
{IP: "203.0.113.3", Hits: 1, Provider: "识别失败"},
{IP: "203.0.113.4", Hits: 1, Provider: ""},
},
Now: start,
})
// 只有第一条算云主机:剩下三种都是「不知道」,不能当成云。
require.Equal(t, 1, sum.CloudSources)
}
// TestBuildEmpty 覆盖空窗口:报告仍要能生成(「这周没人打」是个结论)。
func TestBuildEmpty(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sum := Build(Input{Period: period, Now: start})
require.True(t, sum.Empty)
require.Equal(t, int64(0), sum.TotalHits)
// 每一天都要有 0 值占位,否则趋势图会把「无数据」画成断档。
require.Len(t, sum.Days, 7)
}
// TestBuildDailySeries 覆盖每日趋势:无命中的日子必须补 0。
func TestBuildDailySeries(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
stamps := []time.Time{
start.Add(2 * time.Hour), // 10-19
start.Add(3 * time.Hour), // 10-19
start.AddDate(0, 0, 4).Add(time.Hour), // 10-23
// 边界外:下一周期的第一天第一秒不能算进来(左闭右开)
period.End,
}
sum := Build(Input{Period: period, Timestamps: stamps, Now: start})
require.Len(t, sum.Days, 7)
require.Equal(t, int64(2), sum.Days[0].Hits)
require.Equal(t, int64(0), sum.Days[1].Hits)
require.Equal(t, int64(1), sum.Days[4].Hits)
require.Equal(t, int64(2), sum.MaxDay)
// TotalHits 由「来源聚合」得出,与「时间戳聚合」是两条独立输入;
// 本用例只给了时间戳,所以 TotalHits 为 0。真实调用里两者同窗口同表,
// 数值必定一致(见 internal/cmd/report.go 的 buildSecurityReport)。
require.Equal(t, int64(0), sum.TotalHits)
}
+135
View File
@@ -0,0 +1,135 @@
// 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 report
import "strings"
// countryNames 常见国家/地区的中文显示名。
//
// 报告是给中文读者看的,表格里一列「United States」一列「China」看着像没做完。
// 只覆盖实际可能出现的来源国(攻击流量高度集中在少数国家/地区 + 一批云机房
// 所在地),查不到的直接用 IP 库给的英文名 —— 不做「猜」,也不引入几百行的
// 全量对照表。
var countryNames = map[string]string{
"CN": "中国",
"HK": "中国香港",
"MO": "中国澳门",
"TW": "中国台湾",
"US": "美国",
"JP": "日本",
"KR": "韩国",
"SG": "新加坡",
"MY": "马来西亚",
"TH": "泰国",
"VN": "越南",
"PH": "菲律宾",
"ID": "印度尼西亚",
"IN": "印度",
"PK": "巴基斯坦",
"BD": "孟加拉国",
"LK": "斯里兰卡",
"NP": "尼泊尔",
"KH": "柬埔寨",
"MM": "缅甸",
"MN": "蒙古",
"KZ": "哈萨克斯坦",
"UZ": "乌兹别克斯坦",
"AE": "阿联酋",
"SA": "沙特阿拉伯",
"IL": "以色列",
"TR": "土耳其",
"IQ": "伊拉克",
"IR": "伊朗",
"QA": "卡塔尔",
"KW": "科威特",
"JO": "约旦",
"LB": "黎巴嫩",
"RU": "俄罗斯",
"UA": "乌克兰",
"BY": "白俄罗斯",
"PL": "波兰",
"DE": "德国",
"FR": "法国",
"GB": "英国",
"IE": "爱尔兰",
"NL": "荷兰",
"BE": "比利时",
"LU": "卢森堡",
"CH": "瑞士",
"AT": "奥地利",
"CZ": "捷克",
"SK": "斯洛伐克",
"HU": "匈牙利",
"RO": "罗马尼亚",
"BG": "保加利亚",
"GR": "希腊",
"IT": "意大利",
"ES": "西班牙",
"PT": "葡萄牙",
"SE": "瑞典",
"NO": "挪威",
"DK": "丹麦",
"FI": "芬兰",
"EE": "爱沙尼亚",
"LV": "拉脱维亚",
"LT": "立陶宛",
"IS": "冰岛",
"RS": "塞尔维亚",
"HR": "克罗地亚",
"SI": "斯洛文尼亚",
"MD": "摩尔多瓦",
"CA": "加拿大",
"MX": "墨西哥",
"BR": "巴西",
"AR": "阿根廷",
"CL": "智利",
"CO": "哥伦比亚",
"PE": "秘鲁",
"VE": "委内瑞拉",
"EC": "厄瓜多尔",
"UY": "乌拉圭",
"PY": "巴拉圭",
"BO": "玻利维亚",
"PA": "巴拿马",
"CR": "哥斯达黎加",
"AU": "澳大利亚",
"NZ": "新西兰",
"ZA": "南非",
"EG": "埃及",
"NG": "尼日利亚",
"KE": "肯尼亚",
"MA": "摩洛哥",
"DZ": "阿尔及利亚",
"TN": "突尼斯",
"GH": "加纳",
"ET": "埃塞俄比亚",
"TZ": "坦桑尼亚",
"SC": "塞舌尔",
"MU": "毛里求斯",
"CY": "塞浦路斯",
"MT": "马耳他",
"AL": "阿尔巴尼亚",
"MK": "北马其顿",
"BA": "波黑",
"GE": "格鲁吉亚",
"AM": "亚美尼亚",
"AZ": "阿塞拜疆",
"AF": "阿富汗",
}
// countryName 返回国家/地区的显示名;没收录的退回 IP 库给的英文名。
//
// 港澳台显式写成「中国香港 / 中国澳门 / 中国台湾」:它们是中国的组成部分,
// 在报告里跟「United States」并列成同级「国家」是不对的。这类 IP 库通常
// 单独编码 HK/MO/TW,所以必须在展示层统一,而不是指望数据源。
func countryName(code, fallback string) string {
if n, ok := countryNames[strings.ToUpper(strings.TrimSpace(code))]; ok {
return n
}
if fallback != "" {
return fallback
}
return "未知"
}
+156
View File
@@ -0,0 +1,156 @@
// 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 report
import (
"context"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
)
// 缓存 TTL。
//
// 成功结果留 90 天:一个 IP 的国家/省份几乎不会变,城市级偶尔变,
// 90 天足够新。失败结果只留 3 天:失败多为临时原因(对方 API 超时、
// 限流),留久了等于永久放弃这个地址。
const (
geoSuccessTTL = 90 * 24 * time.Hour
geoFailureTTL = 3 * 24 * time.Hour
)
// GeoResolver 是定位器的最小接口(*geoip.Resolver 实现它)。
//
// 用接口而不是具体类型:这个函数的核心是缓存语义(TTL、失败短期不重试、
// 写回),必须能在不联网、不依赖真实 IP 库的情况下测到。
type GeoResolver interface {
Lookup(ctx context.Context, keys []string) geoip.Result
Enabled() bool
}
// ResolveWithCache 解析一批 IP 的地理位置,优先用缓存。
//
// keys 是 shared.LimitKey 的产物,顺序会被保留用于「腾讯精修取前 N 个」——
// 调用方应按命中次数从高到低传入,这样配额优先花在最活跃的来源上。
//
// ⚠️ 写缓存的判定是这里全部的价值所在,务必分清三种结果:
// - Result.Geo[key] 存在 → 查到了,写成功缓存(90 天)
// - Result.Unresolved[key] → 服务明确说没有这个地址的数据,写失败缓存(3 天),
// 免得每份报告都去重试一个注定查不到的地址
// - 两个 map 里都没有 → 这次请求本身没成功(超时 / 限流 / 网关 5xx)。
// **绝不写缓存**。早先的实现在这里也写 Failed,结果是一次网络抖动就把整批
// IP 钉成「查不到」三天,此后每份报告的定位覆盖率都莫名其妙变成 0,
// 而且日志里没有任何线索指向真正的原因。
func ResolveWithCache(ctx context.Context, cache db.IPGeoStore, resolver GeoResolver, keys []string) map[string]geoip.Geo {
out := make(map[string]geoip.Geo, len(keys))
if len(keys) == 0 {
return out
}
cached, err := cache.GetMany(ctx, keys)
if err != nil {
// 缓存读失败不该让报告失败:退化成「全部现查」,
// 代价是可能多花点配额,但报告照出。
logrus.WithError(err).Warn("读取 IP 地理缓存失败,本次将全部重新查询")
cached = nil
}
now := time.Now()
var misses []string
seen := make(map[string]bool, len(keys))
for _, key := range keys {
if seen[key] {
continue
}
seen[key] = true
rec, ok := cached[key]
if ok && fresh(rec, now) {
if !rec.Failed {
out[key] = geoip.Geo{
Country: rec.Country,
CountryCode: rec.CountryCode,
Region: rec.Region,
City: rec.City,
ISP: rec.ISP,
ASN: rec.ASN,
Lat: rec.Lat,
Lon: rec.Lon,
HasCoords: rec.HasCoords,
Source: rec.Source,
}
}
// 命中但 Failed:明确记为「查不到」,不再重试,也不进 out。
continue
}
misses = append(misses, key)
}
if len(misses) == 0 || !resolver.Enabled() {
return out
}
resolved := resolver.Lookup(ctx, misses)
if resolved.Failures > 0 {
logrus.WithFields(logrus.Fields{
"keys": len(misses),
"requests": resolved.Requests,
"failures": resolved.Failures,
"located": len(resolved.Geo),
}).Warn("IP 定位请求部分失败:失败的那些不进缓存,下次报告会重新查询")
}
// 写回缓存:成功的与「服务明确说没有」的都写,网络失败的跳过。
records := make([]*db.IPGeoRecord, 0, len(misses))
for _, key := range misses {
if g, ok := resolved.Geo[key]; ok {
out[key] = g
records = append(records, &db.IPGeoRecord{
IPKey: key,
Country: g.Country,
CountryCode: g.CountryCode,
Region: g.Region,
City: g.City,
ISP: g.ISP,
ASN: g.ASN,
Lat: g.Lat,
Lon: g.Lon,
HasCoords: g.HasCoords,
Source: g.Source,
ResolvedAt: now,
})
continue
}
if resolved.Unresolved[key] {
records = append(records, &db.IPGeoRecord{
IPKey: key,
ResolvedAt: now,
Failed: true,
})
}
}
if len(records) == 0 {
return out
}
if err := cache.UpsertMany(ctx, records); err != nil {
logrus.WithError(err).Warn("写入 IP 地理缓存失败(不影响本次报告)")
}
return out
}
// fresh 判断缓存记录是否还在有效期内。
func fresh(rec *db.IPGeoRecord, now time.Time) bool {
if rec == nil {
return false
}
ttl := geoSuccessTTL
if rec.Failed {
ttl = geoFailureTTL
}
return now.Sub(rec.ResolvedAt) < ttl
}
+202
View File
@@ -0,0 +1,202 @@
// 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 report
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
)
// fakeGeoStore 内存版缓存,避免测试依赖数据库。
type fakeGeoStore struct {
rows map[string]*db.IPGeoRecord
upserts int
}
func newFakeGeoStore() *fakeGeoStore {
return &fakeGeoStore{rows: map[string]*db.IPGeoRecord{}}
}
func (f *fakeGeoStore) GetMany(_ context.Context, keys []string) (map[string]*db.IPGeoRecord, error) {
out := map[string]*db.IPGeoRecord{}
for _, k := range keys {
if r, ok := f.rows[k]; ok {
out[k] = r
}
}
return out, nil
}
func (f *fakeGeoStore) UpsertMany(_ context.Context, records []*db.IPGeoRecord) error {
f.upserts += len(records)
for _, r := range records {
f.rows[r.IPKey] = r
}
return nil
}
func (f *fakeGeoStore) PurgeResolvedBefore(context.Context, time.Time, bool) (int64, error) {
return 0, nil
}
func (f *fakeGeoStore) Count(context.Context) (int64, error) { return int64(len(f.rows)), nil }
// fakeResolver 记录被查询的键,按预设返回。
//
// 三态要能分别构造:result 里的是查到了,unresolved 里的是服务明确说没有,
// 两者都没有的算请求失败(failures)。
type fakeResolver struct {
enabled bool
result map[string]geoip.Geo
unresolved map[string]bool
failures int
queried []string
}
func (f *fakeResolver) Lookup(_ context.Context, keys []string) geoip.Result {
f.queried = append(f.queried, keys...)
r := geoip.Result{
Geo: map[string]geoip.Geo{},
Unresolved: map[string]bool{},
Requests: 1,
Failures: f.failures,
}
for _, k := range keys {
if g, ok := f.result[k]; ok {
r.Geo[k] = g
continue
}
if f.unresolved[k] {
r.Unresolved[k] = true
}
}
return r
}
func (f *fakeResolver) Enabled() bool { return f.enabled }
// TestResolveWithCache 覆盖核心缓存语义:命中不查、未命中才查、明确无数据也回写。
func TestResolveWithCache(t *testing.T) {
ctx := context.Background()
store := newFakeGeoStore()
now := time.Now()
// 已缓存的旧记录(30 天前,仍在 90 天 TTL 内)
store.rows["1.1.1.1"] = &db.IPGeoRecord{
IPKey: "1.1.1.1", Country: "澳大利亚", CountryCode: "AU",
City: "Sydney", HasCoords: true, Lat: -33.8, Lon: 151.2,
Source: "ipapi", ResolvedAt: now.AddDate(0, 0, -30),
}
// 已缓存的失败记录(1 天前,仍在 3 天 TTL 内)→ 不重试
store.rows["2.2.2.2"] = &db.IPGeoRecord{
IPKey: "2.2.2.2", Failed: true, ResolvedAt: now.AddDate(0, 0, -1),
}
resolver := &fakeResolver{
enabled: true,
result: map[string]geoip.Geo{
"3.3.3.3": {Country: "德国", CountryCode: "DE", City: "Berlin", Lat: 52.5, Lon: 13.4, HasCoords: true, Source: "ipapi"},
},
// 4.4.4.4:服务明确回答查不到
unresolved: map[string]bool{"4.4.4.4": true},
}
got := ResolveWithCache(ctx, store, resolver, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4"})
// 缓存命中的值要能取回来
require.Equal(t, "澳大利亚", got["1.1.1.1"].Country)
// 失败记录不进结果,也不重新查询
require.NotContains(t, got, "2.2.2.2")
// 未命中的查到了
require.Equal(t, "德国", got["3.3.3.3"].Country)
// 查不到的也不进结果
require.NotContains(t, got, "4.4.4.4")
// 只应查询两个真正需要查的键
require.ElementsMatch(t, []string{"3.3.3.3", "4.4.4.4"}, resolver.queried)
// 两条未命中的都要写回缓存(成功的 + 明确无数据的)
require.Equal(t, 2, store.upserts)
require.True(t, store.rows["4.4.4.4"].Failed, "服务明确说没数据时必须落 Failed 标记,否则下一份报告会反复重试")
require.False(t, store.rows["3.3.3.3"].Failed)
}
// TestResolveWithCacheNetworkFailure 是「缓存毒化」的回归测试。
//
// 网络失败(超时 / 限流 / 网关 5xx)绝不能被记成「这个 IP 没有位置」:
// 一旦写进缓存就是 3 天,此后每份报告的定位覆盖率都会莫名其妙变成 0。
func TestResolveWithCacheNetworkFailure(t *testing.T) {
ctx := context.Background()
store := newFakeGeoStore()
// 三个键全都没查成:result 与 unresolved 都是空的
resolver := &fakeResolver{enabled: true, failures: 1}
got := ResolveWithCache(ctx, store, resolver, []string{"8.8.8.8", "9.9.9.9"})
require.Empty(t, got)
require.Len(t, resolver.queried, 2, "失败也要算查过了,否则同一轮里会重复发请求")
require.Equal(t, 0, store.upserts, "请求失败绝不能写缓存")
require.Empty(t, store.rows)
}
// TestResolveWithCacheTTL 覆盖过期语义:成功记录 90 天后、失败记录 3 天后都要重查。
func TestResolveWithCacheTTL(t *testing.T) {
ctx := context.Background()
now := time.Now()
store := newFakeGeoStore()
store.rows["5.5.5.5"] = &db.IPGeoRecord{
IPKey: "5.5.5.5", Country: "旧数据", HasCoords: true,
ResolvedAt: now.AddDate(0, 0, -91), // 超过 90 天
}
store.rows["6.6.6.6"] = &db.IPGeoRecord{
IPKey: "6.6.6.6", Failed: true,
ResolvedAt: now.AddDate(0, 0, -4), // 超过 3 天
}
resolver := &fakeResolver{
enabled: true,
result: map[string]geoip.Geo{
"5.5.5.5": {Country: "新数据", CountryCode: "US", Source: "ipapi", HasCoords: true},
"6.6.6.6": {Country: "韩国", CountryCode: "KR", Source: "ipapi", HasCoords: true},
},
}
got := ResolveWithCache(ctx, store, resolver, []string{"5.5.5.5", "6.6.6.6"})
require.Equal(t, "新数据", got["5.5.5.5"].Country)
require.Equal(t, "韩国", got["6.6.6.6"].Country, "过期的失败记录必须重试")
require.Len(t, resolver.queried, 2)
}
// TestResolveWithCacheDisabled 覆盖 ip_geo_provider = none:一个字节都不外发。
func TestResolveWithCacheDisabled(t *testing.T) {
ctx := context.Background()
store := newFakeGeoStore()
resolver := &fakeResolver{enabled: false}
got := ResolveWithCache(ctx, store, resolver, []string{"1.2.3.4"})
require.Empty(t, got)
require.Empty(t, resolver.queried)
require.Equal(t, 0, store.upserts)
}
// TestResolveWithCacheDedup 覆盖重复键只查一次。
func TestResolveWithCacheDedup(t *testing.T) {
ctx := context.Background()
store := newFakeGeoStore()
resolver := &fakeResolver{
enabled: true,
result: map[string]geoip.Geo{"7.7.7.7": {Country: "法国", Source: "ipapi"}},
}
ResolveWithCache(ctx, store, resolver, []string{"7.7.7.7", "7.7.7.7", "7.7.7.7"})
require.Equal(t, []string{"7.7.7.7"}, resolver.queried)
require.Equal(t, 1, store.upserts)
}
+391
View File
@@ -0,0 +1,391 @@
// 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 report
import (
"encoding/base64"
"fmt"
"html"
"math"
"os"
"path/filepath"
"strings"
"time"
)
// HTMLOptions 渲染选项。
type HTMLOptions struct {
// TencentMapKey 腾讯位置服务 key。留空则报告里只有离线 SVG 地图,
// 并附一句「需要可交互底图请配置 ip_geo_tencent_key」。
TencentMapKey string
}
// tencentKeyPlaceholder 未配置 key 时写进页面的占位符。
//
// 按地图合规要求,代码里绝不预置任何可用的 key;占位符要让拿到报告的人
// 一眼看出「这里需要你自己申请」,而不是以为地图坏了。
const tencentKeyPlaceholder = "请前往 lbs.qq.com 申请 key 后填入 [security] ip_geo_tencent_key"
// RenderHTML 生成完整的自包含报告页。
//
// 自包含的含义:不依赖 site 的 CSS、不依赖任何外链图片(腾讯地图那一块是
// 可选的在线增强,其余部分断网也能看)。
func RenderHTML(sum *Summary, opts HTMLOptions) string {
var b strings.Builder
title := sum.Period.Title()
b.WriteString(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">`)
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1">`)
fmt.Fprintf(&b, `<title>%s</title>`, html.EscapeString(title))
b.WriteString(pageCSS)
b.WriteString(`</head><body><div class="wrap">`)
// 头部
fmt.Fprintf(&b, `<header><h1>%s</h1><p class="sub">统计窗口 <b>%s</b> · 生成于 %s</p></header>`,
html.EscapeString(title), html.EscapeString(sum.Period.Label()),
sum.GeneratedAt.Format("2006-01-02 15:04"))
if sum.Empty {
b.WriteString(`<div class="empty">本期没有捕获到任何蜜罐命中 —— 这本身是个好消息,但也请确认蜜罐仍然开启(<code>TamaBox honeypot paths</code>)。</div>`)
b.WriteString(`</div></body></html>`)
return b.String()
}
// KPI
b.WriteString(`<section class="kpis">`)
writeKPI(&b, "蜜罐命中", fmt.Sprint(sum.TotalHits), trendNote(sum.TotalHits, sum.PrevHits))
writeKPI(&b, "独立来源", fmt.Sprint(sum.UniqueSources), fmt.Sprintf("覆盖 %d 个国家和地区", sum.UniqueCountry))
writeKPI(&b, "已拦截", fmt.Sprint(sum.BannedHits), pct(sum.BannedHits, sum.TotalHits))
writeKPI(&b, "真实破解尝试", fmt.Sprint(sum.CredHits), "提交了账号密码")
writeKPI(&b, "云主机来源", fmt.Sprint(sum.CloudSources), fmt.Sprintf("占比 %s", pctInt(int64(sum.CloudSources), int64(sum.UniqueSources))))
b.WriteString(`</section>`)
// 地图
b.WriteString(`<section><h2>来源分布</h2>`)
b.WriteString(`<p class="note">气泡面积与命中次数成正比。地图上的点是 IP 库给出的城市级定位,精度到城市,不代表对方的真实街道位置。</p>`)
b.WriteString(`<div class="map-frame">`)
b.WriteString(RenderSVG(sum))
b.WriteString(`</div>`)
b.WriteString(`<p class="note">⚠️ 本图只画经纬网与气泡,<b>不含任何国界</b>。国界、台湾与南海诸岛的正确绘制须符合国家标准,自绘底图容易出错,因此这里不做底图;需要真实底图请看下方的腾讯地图版本。</p>`)
b.WriteString(`</section>`)
// 腾讯可交互地图
b.WriteString(`<section><h2>可交互地图</h2>`)
b.WriteString(renderTencentMap(sum, opts.TencentMapKey))
b.WriteString(`</section>`)
// 国家排行
b.WriteString(`<section><h2>国家 / 地区排行</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>国家 / 地区</th><th class="num">命中</th><th class="num">占比</th><th class="num">独立来源</th></tr></thead><tbody>`)
for i, c := range sum.Countries {
if i >= 20 {
break
}
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s <span class="muted">%s</span></td><td class="num">%d</td><td class="num">%s</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(c.Name), html.EscapeString(c.Code), c.Hits, pctFloat(c.Share), c.Sources)
}
b.WriteString(`</tbody></table></section>`)
// 城市排行
if len(sum.Cities) > 0 {
b.WriteString(`<section><h2>城市排行(前 20)</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>城市</th><th>国家</th><th class="num">命中</th><th class="num">独立来源</th></tr></thead><tbody>`)
for i, c := range sum.Cities {
if i >= 20 {
break
}
city := c.City
if city == "" {
city = c.Region
}
if city == "" {
city = "(未知城市)"
}
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s</td><td>%s</td><td class="num">%d</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(city), html.EscapeString(c.Country), c.Hits, c.Sources)
}
b.WriteString(`</tbody></table></section>`)
}
// 来源明细
b.WriteString(`<section><h2>来源明细(前 50)</h2>`)
b.WriteString(`<table><thead><tr><th>IP</th><th>位置</th><th>运营商 / ASN</th><th>网络归属</th><th class="num">命中</th><th class="num">破解</th><th class="num">拦截</th></tr></thead><tbody>`)
for i, s := range sum.Sources {
if i >= 50 {
break
}
provider := s.Provider
if provider == "" {
provider = "—"
}
isp := s.ISP
if s.ASN != "" {
if isp != "" {
isp += " · "
}
isp += s.ASN
}
if isp == "" {
isp = "—"
}
fmt.Fprintf(&b, `<tr><td class="mono">%s</td><td>%s</td><td class="small">%s</td><td class="small">%s</td><td class="num">%d</td><td class="num">%d</td><td class="num">%d</td></tr>`,
html.EscapeString(s.IP), html.EscapeString(s.Location()),
html.EscapeString(isp), html.EscapeString(provider),
s.Hits, s.Creds, s.Banned)
}
b.WriteString(`</tbody></table>`)
if len(sum.Sources) >= 50 {
fmt.Fprintf(&b, `<p class="note">共 %d 个独立来源,此处只列命中次数最高的 50 个。完整明细:<code>TamaBox honeypot stats --days %d</code></p>`,
sum.UniqueSources, int(sum.Period.End.Sub(sum.Period.Start).Hours()/24))
}
b.WriteString(`</section>`)
// 路径
if len(sum.Paths) > 0 {
b.WriteString(`<section><h2>被探测路径</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>路径</th><th class="num">次数</th></tr></thead><tbody>`)
for i, p := range sum.Paths {
if i >= 20 {
break
}
fmt.Fprintf(&b, `<tr><td>%d</td><td class="mono">%s</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(p.Path), p.Hits)
}
b.WriteString(`</tbody></table></section>`)
}
// 趋势
if len(sum.Days) > 1 {
b.WriteString(`<section><h2>每日趋势</h2>`)
b.WriteString(`<table class="trend"><tbody>`)
for _, d := range sum.Days {
w := 0.0
if sum.MaxDay > 0 {
w = float64(d.Hits) / float64(sum.MaxDay) * 100
}
// 宽度为 0 的日子也给 1px,否则读起来像「数据缺失」而不是「零」。
barW := math.Max(w, 0.6)
fmt.Fprintf(&b, `<tr><td class="day">%s</td><td class="bar"><span style="width:%.2f%%"></span></td><td class="num">%d</td></tr>`,
d.Day.Format("01-02"), barW, d.Hits)
}
b.WriteString(`</tbody></table></section>`)
}
// 口径说明
b.WriteString(`<section><h2>口径与说明</h2><ul class="meta">`)
fmt.Fprintf(&b, `<li>地理定位数据源:%s。</li>`, html.EscapeString(sum.ResolverDesc))
fmt.Fprintf(&b, `<li>未能定位的命中:<b>%d</b> 次(占 %s)。这些 IP 在国内外的 IP 库里都没有城市级记录,通常是被回收中的地址段或运营商级 NAT。</li>`,
sum.UnlocatedHits, pct(sum.UnlocatedHits, sum.TotalHits))
b.WriteString(`<li>「破解」指对该次请求提交了账号密码字段;纯路径探测不计入。捕获到的账号密码本身不写进本报告,存放在服务器的取证目录(<code>TamaBox honeypot hits</code> 查看)。</li>`)
b.WriteString(`<li>「网络归属」来自离线指纹(cdncheck),判定的是云厂商 / CDN / WAF,<b>不是</b>地理位置。显示「未识别(普通网络)」不代表没定位到,两者是不同的维度。</li>`)
b.WriteString(`<li>独立来源按封禁口径聚合:IPv4 精确到地址,IPv6 折叠到 /64 —— 攻击者常在一个 /64 内轮换地址,按精确地址会把一台机器算成几十个。</li>`)
b.WriteString(`</ul></section>`)
fmt.Fprintf(&b, `<footer>由 TamaBox 蜜罐自动生成 · %s</footer>`, sum.GeneratedAt.Format("2006-01-02 15:04:05"))
b.WriteString(`</div></body></html>`)
return b.String()
}
// renderTencentMap 生成腾讯地图区块。
//
// 合规要点(与 geo-map-compliance-guard 一致):
// - 只用持牌厂商的底图(腾讯位置服务),绝不引 Google/Bing/OSM/Mapbox;
// - 代码里不预置任何可用 key,未配置时给出申请指引;
// - 地图展示的是攻击者 IP 的城市级定位,用于内部安全复盘,不对外发布。
func renderTencentMap(sum *Summary, key string) string {
key = strings.TrimSpace(key)
if key == "" {
return fmt.Sprintf(`<div class="hint">未配置腾讯地图 key,本节跳过。<br>如需可缩放的真实底图,%s。</div>`,
html.EscapeString(tencentKeyPlaceholder))
}
pts := bubbles(sum)
if len(pts) > 120 {
pts = pts[:120] // 地图上挤 500 个点既卡又看不清
}
maxHits := maxCityHits(pts)
var geo strings.Builder
geo.WriteString("[")
for i, p := range pts {
if i > 0 {
geo.WriteString(",")
}
// 腾讯地图用 GCJ-02。ip-api 给的是 WGS-84(与 GCJ-02 差几百米),
// 在城市级缩放上无影响;放大到街道时才有偏差。
fmt.Fprintf(&geo, `{"id":"p%d","styleId":"s%d","position":[%f,%f],"title":"%s %d 次"}`,
i, sizeClass(p.Hits, maxHits), p.Lat, p.Lon,
jsEscape(p.City+p.Country), p.Hits)
}
geo.WriteString("]")
return fmt.Sprintf(`<div id="tmap" class="tmap"></div>
<div id="tmap-status" class="note"></div>
<script src="https://map.qq.com/api/gljs?v=1.exp&key=%s"></script>
<script>
(function () {
if (typeof TMap === 'undefined') {
document.getElementById('tmap-status').textContent = '腾讯地图 SDK 未能加载(可能是离线打开或 key 无效),请参考上方离线地图。';
return;
}
var center = new TMap.LatLng(20, 10);
var map = new TMap.Map('tmap', { center: center, zoom: 3, viewMode: '2D' });
var styles = {};
var sizes = [[8,8],[12,12],[18,18],[26,26],[34,34]];
for (var i = 0; i < sizes.length; i++) {
styles['s' + i] = new TMap.MarkerStyle({
width: sizes[i][0], height: sizes[i][1],
anchor: { x: sizes[i][0]/2, y: sizes[i][1]/2 },
color: '#EF4444'
});
}
new TMap.MultiMarker({ map: map, styles: styles, geometries: %s });
document.getElementById('tmap-status').textContent = '共 %d 个城市级定位点,气泡越大命中越多。点击标记可见城市与次数。';
})();
</script>`,
html.EscapeString(key), geo.String(), len(pts))
}
// sizeClass 按命中数把气泡分成 5 档(地图标记的尺寸样式只有有限几档)。
func sizeClass(hits, maxHits int64) int {
if maxHits <= 0 {
return 0
}
r := float64(hits) / float64(maxHits)
switch {
case r > 0.8:
return 4
case r > 0.5:
return 3
case r > 0.25:
return 2
case r > 0.08:
return 1
default:
return 0
}
}
// jsEscape 转义要嵌进 JS 字符串字面量的文本。
// 先做 HTML 转义(防 </script> 逃逸),再转义引号与反斜杠。
func jsEscape(s string) string {
s = html.EscapeString(s)
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
return s
}
func writeKPI(b *strings.Builder, label, value, note string) {
b.WriteString(`<div class="kpi">`)
fmt.Fprintf(b, `<div class="kpi-label">%s</div><div class="kpi-value">%s</div>`, html.EscapeString(label), html.EscapeString(value))
if note != "" {
fmt.Fprintf(b, `<div class="kpi-note">%s</div>`, html.EscapeString(note))
}
b.WriteString(`</div>`)
}
// trendNote 给出与上一周期的对比。
func trendNote(cur, prev int64) string {
if prev <= 0 {
return "上期无数据"
}
delta := float64(cur-prev) / float64(prev) * 100
arrow := "↑"
if delta < 0 {
arrow = "↓"
}
return fmt.Sprintf("较上期 %s %.0f%%(上期 %d)", arrow, math.Abs(delta), prev)
}
func pct(v, total int64) string {
if total <= 0 {
return "—"
}
return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100)
}
func pctInt(v, total int64) string { return pct(v, total) }
func pctFloat(f float64) string { return fmt.Sprintf("%.1f%%", f*100) }
// WriteFiles 把报告落盘,返回 HTML 与 PNG 的路径。
//
// 目录按 0700 创建:报告里有攻击者 IP 明细,不是可以随便给别的用户读的文件。
func WriteFiles(sum *Summary, dir string, opts HTMLOptions) (htmlPath, pngPath string, err error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", err
}
base := filepath.Join(dir, sum.Period.Filename())
htmlPath = base + ".html"
if err = os.WriteFile(htmlPath, []byte(RenderHTML(sum, opts)), 0o600); err != nil {
return "", "", err
}
png, err := RenderPNG(sum)
if err != nil {
return htmlPath, "", err
}
pngPath = base + ".png"
if err = os.WriteFile(pngPath, png, 0o600); err != nil {
return htmlPath, "", err
}
return htmlPath, pngPath, nil
}
// PNGDataURI 把地图转成 data URI(备用:某些邮件客户端对 CID 附件不友好时
// 可以退化为内联 data URI —— 代价是邮件体积变大且部分客户端会拦)。
func PNGDataURI(sum *Summary) (string, error) {
b, err := RenderPNG(sum)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(b), nil
}
// pageCSS 报告页样式。刻意内联:报告的 HTML 会被单独转发、离线打开,
// 不能依赖站点的静态资源(那些 URL 在别人机器上打不开)。
const pageCSS = `<style>
:root{--fg:#0f172a;--muted:#64748b;--line:#e2e8f0;--bg:#ffffff;--accent:#dc2626;--tint:#f8fafc}
*{box-sizing:border-box}
body{margin:0;background:var(--tint);color:var(--fg);font:14px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
.wrap{max-width:1040px;margin:0 auto;padding:32px 20px 60px}
header h1{margin:0 0 4px;font-size:24px;letter-spacing:-.01em}
header .sub{margin:0;color:var(--muted);font-size:13px}
section{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:20px;margin-top:18px}
h2{font-size:15px;margin:0 0 12px;font-weight:600}
.note{color:var(--muted);font-size:12.5px;margin:8px 0 0}
.muted{color:var(--muted)}
.small{font-size:12.5px;color:var(--muted)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:18px}
.kpi{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.kpi-label{font-size:12px;color:var(--muted)}
.kpi-value{font-size:26px;font-weight:650;letter-spacing:-.02em;margin:2px 0}
.kpi-note{font-size:12px;color:var(--muted)}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-weight:600;color:var(--muted);font-size:12px;background:var(--tint)}
.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.map-frame{border:1px solid var(--line);border-radius:8px;overflow:hidden;line-height:0}
.tmap{height:420px;border:1px solid var(--line);border-radius:8px}
.hint{background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:12px 14px;color:#9a3412;font-size:13px}
.empty{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:28px;text-align:center;color:var(--muted)}
table.trend td{border:0;padding:2px 8px}
table.trend .day{color:var(--muted);font-size:12px;white-space:nowrap;width:60px}
table.trend .bar{padding:0 8px}
table.trend .bar span{display:block;height:12px;background:linear-gradient(90deg,#f87171,#dc2626);border-radius:2px;min-width:1px}
table.trend .num{width:70px}
ul.meta{margin:0;padding-left:18px;color:#334155;font-size:13px}
ul.meta li{margin-bottom:6px}
footer{margin-top:26px;text-align:center;color:var(--muted);font-size:12px}
code{background:var(--tint);border:1px solid var(--line);border-radius:4px;padding:1px 5px;font-size:12px}
@media print{body{background:#fff}section{break-inside:avoid;border-color:#ccc}}
</style>`
// FormatTime 供模板层统一时间格式。
func FormatTime(t time.Time) string { return t.Format("2006-01-02 15:04") }
+476
View File
@@ -0,0 +1,476 @@
// 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 report
import (
"bytes"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"sort"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
// 画布尺寸。2:1 是等距圆柱(equirectangular)投影的天然比例:
// 经度 360°、纬度 180°,正好铺满矩形。
const (
mapWidth = 960
mapHeight = 480
)
// 配色。刻意用浅色:邮件模板是白底,深色地图嵌进去像个补丁。
var (
colOcean = color.RGBA{0xF1, 0xF5, 0xF9, 0xFF}
colGrid = color.RGBA{0xCB, 0xD5, 0xE1, 0xFF}
colGridKey = color.RGBA{0x94, 0xA3, 0xB8, 0xFF}
colBubble = color.RGBA{0xEF, 0x44, 0x44, 0xFF}
colRim = color.RGBA{0xB9, 0x1C, 0x1C, 0xFF}
colText = color.RGBA{0x33, 0x41, 0x55, 0xFF}
)
// project 把经纬度映射到画布坐标(等距圆柱投影)。
//
// ⚠️ 这里不画任何海岸线或国界。原因不是偷懒:国界、台湾与南海诸岛的绘制
// 必须符合国家标准,自绘底图极易画错,而画错地图是比「图不好看」严重得多
// 的问题。需要真实底图时用腾讯地图(本包另出的 HTML 版带它)。
// 经纬网 + 气泡已经足以回答「攻击主要来自哪个半球/哪个区域」。
func project(lat, lon float64) (x, y float64) {
x = (lon + 180) / 360 * mapWidth
y = (90 - lat) / 180 * mapHeight
return x, y
}
// bubbleRadius 按命中数相对最大值的平方根缩放。
//
// 用 sqrt 而不是线性:几次命中和上千次命中如果按线性画,前者会小到看不见;
// 面积正比于数值是气泡图的通行做法(人眼比较的是面积而不是半径)。
func bubbleRadius(hits, maxHits int64) float64 {
if maxHits <= 0 {
return 4
}
ratio := math.Sqrt(float64(hits) / float64(maxHits))
return 3.5 + 17*ratio
}
// bubbles 从城市聚合里挑出可画的点,按命中数降序。
func bubbles(sum *Summary) []CityRow {
pts := make([]CityRow, 0, len(sum.Cities))
for _, c := range sum.Cities {
// 坐标越界(IP 库偶尔给出 0,0 或越界值)直接丢弃:
// 画到几内亚湾比不画更误导。
if math.IsNaN(c.Lat) || math.IsNaN(c.Lon) ||
c.Lat < -90 || c.Lat > 90 || c.Lon < -180 || c.Lon > 180 {
continue
}
if c.Lat == 0 && c.Lon == 0 {
continue
}
pts = append(pts, c)
}
sort.Slice(pts, func(i, j int) bool {
if pts[i].Hits != pts[j].Hits {
return pts[i].Hits > pts[j].Hits
}
return pts[i].City < pts[j].City
})
return pts
}
// maxCityHits 返回城市命中数的最大值(气泡缩放基准)。
func maxCityHits(pts []CityRow) int64 {
var m int64
for _, p := range pts {
if p.Hits > m {
m = p.Hits
}
}
return m
}
// graticule 经纬网:经度每 30°、纬度每 30° 一条,赤道与本初子午线加粗。
func graticule() (xs, ys []float64) {
for lon := -180.0; lon <= 180; lon += 30 {
xs = append(xs, lon)
}
for lat := -90.0; lat <= 90; lat += 30 {
ys = append(ys, lat)
}
return xs, ys
}
// labelBox 已放好的标签占位框。
type labelBox struct {
x0, y0, x1, y1 float64
}
// labelPacker 给标签找不重叠的位置。
//
// 为什么必须做:同一个都市圈的不同城市,经纬度只差 0.01°,标签会精确地压在
// 一起,两行字叠成一串谁也读不出的字符。实测出现过「AU 305」这种由
// 「AU 55」与「AU 30」叠出来的假数字 —— 这比不标更糟:读者会把它当真实数据。
// 宁可少标几个,也不能让图上出现一个读错就是错的数字。
type labelPacker struct {
placed []labelBox
width, height float64
}
func newLabelPacker(width, height float64) *labelPacker {
return &labelPacker{width: width, height: height}
}
// place 在气泡右侧找位置,放不下就换左侧,两侧都占则放弃。
// 返回标签框左上角坐标。
func (p *labelPacker) place(bx, by, r, textW, textH float64) (x, y float64, ok bool) {
top := by - textH/2
for _, cx := range []float64{bx + r + 3, bx - r - 3 - textW} {
if cx < 2 || cx+textW > p.width-2 || top < 1 || top+textH > p.height-1 {
continue
}
box := labelBox{cx - 2, top - 1, cx + textW + 2, top + textH + 1}
if p.collides(box) {
continue
}
p.placed = append(p.placed, box)
return cx, top, true
}
return 0, 0, false
}
func (p *labelPacker) collides(b labelBox) bool {
for _, o := range p.placed {
if b.x0 < o.x1 && o.x0 < b.x1 && b.y0 < o.y1 && o.y0 < b.y1 {
return true
}
}
return false
}
// 经纬度刻度的取值。只标每 60° / 30°,标满会把气泡和标签挤没。
var (
tickLons = []float64{-180, -120, -60, 0, 60, 120, 180}
tickLats = []float64{-60, -30, 0, 30, 60}
)
// latLabel 把纬度格式化成 30N / 30S 这样的刻度。
func latLabel(lat float64) string {
switch {
case lat == 0:
return "0"
case lat > 0:
return fmt.Sprintf("%.0fN", lat)
default:
return fmt.Sprintf("%.0fS", -lat)
}
}
// RenderSVG 生成内联 SVG 世界气泡图。用于 HTML 报告附件(浏览器里看)。
func RenderSVG(sum *Summary) string {
pts := bubbles(sum)
maxHits := maxCityHits(pts)
lons, lats := graticule()
var b bytes.Buffer
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" role="img" aria-label="蜜罐命中来源分布世界地图">`, mapWidth, mapHeight)
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#F1F5F9"/>`, mapWidth, mapHeight)
// 经纬网
for _, lon := range lons {
x, _ := project(0, lon)
stroke := "#CBD5E1"
w := "0.6"
if lon == 0 {
stroke = "#94A3B8"
w = "1"
}
fmt.Fprintf(&b, `<line x1="%.1f" y1="0" x2="%.1f" y2="%d" stroke="%s" stroke-width="%s"/>`, x, x, mapHeight, stroke, w)
}
for _, lat := range lats {
_, y := project(lat, 0)
stroke := "#CBD5E1"
w := "0.6"
if lat == 0 {
stroke = "#94A3B8"
w = "1"
}
fmt.Fprintf(&b, `<line x1="0" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="%s"/>`, y, mapWidth, y, stroke, w)
}
// 经纬度刻度:没有底图时这是读者唯一的方向参照,不能省。
for _, lon := range tickLons {
x, _ := project(0, lon)
if x < 12 {
x = 12
}
if x > mapWidth-12 {
x = mapWidth - 12
}
fmt.Fprintf(&b, `<text x="%.1f" y="%d" font-size="9" fill="#94A3B8" text-anchor="middle" font-family="monospace">%s</text>`,
x, mapHeight-4, lonLabel(lon))
}
for _, lat := range tickLats {
_, y := project(lat, 0)
fmt.Fprintf(&b, `<text x="3" y="%.1f" font-size="9" fill="#94A3B8" font-family="monospace">%s</text>`,
y+3, latLabel(lat))
}
// 气泡
for _, p := range pts {
x, y := project(p.Lat, p.Lon)
r := bubbleRadius(p.Hits, maxHits)
fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="%.1f" fill="#EF4444" fill-opacity="0.35" stroke="#B91C1C" stroke-opacity="0.8" stroke-width="1"><title>%s · %s:%d 次命中(%d 个来源)</title></circle>`,
x, y, r, html.EscapeString(p.City), html.EscapeString(p.Country), p.Hits, p.Sources)
}
// 标注前 8 个城市:放过大的气泡,标签不重叠才画(理由见 labelPacker)。
packer := newLabelPacker(mapWidth, mapHeight)
for i, p := range pts {
if i >= 8 {
break
}
x, y := project(p.Lat, p.Lon)
r := bubbleRadius(p.Hits, maxHits)
label := cityLabel(p)
tx, ty, ok := packer.place(x, y, r, svgTextWidth(label), 12)
if !ok {
continue
}
fmt.Fprintf(&b, `<text x="%.1f" y="%.1f" font-size="11" fill="#334155" font-family="system-ui,-apple-system,Segoe UI,sans-serif">%s</text>`,
tx, ty+10, html.EscapeString(label))
}
b.WriteString(`</svg>`)
return b.String()
}
// cityLabel 优先用城市名,退到省/州,最后退到国家名。
func cityLabel(p CityRow) string {
if p.City != "" {
return fmt.Sprintf("%s %d", p.City, p.Hits)
}
if p.Region != "" {
return fmt.Sprintf("%s %d", p.Region, p.Hits)
}
return fmt.Sprintf("%s %d", p.Country, p.Hits)
}
// svgTextWidth 粗估一行文字在 11px 下的宽度。
// CJK 按全宽算、其余按 0.62 倍字宽算 —— 只用于判断是否会撞在一起,
// 精确到像素没有意义(何况最终字体由浏览器决定)。
func svgTextWidth(s string) float64 {
var w float64
for _, r := range s {
if r > 0x2E80 {
w += 11
} else {
w += 6.8
}
}
return w
}
// lonLabel 把经度格式化成 90°E / 120°W 这样的刻度。
func lonLabel(lon float64) string {
switch {
case lon == 0:
return "0"
case lon > 0:
return fmt.Sprintf("%.0fE", lon)
default:
return fmt.Sprintf("%.0fW", -lon)
}
}
// RenderPNG 生成邮件正文用的位图世界气泡图。
//
// 为什么要位图:邮件客户端不执行 JS,且 Gmail / Outlook 会剥离内联 SVG。
// 只有以 CID 内嵌的 PNG 能保证「打开邮件就看得到地图」。
//
// 图上文字只能是 ASCII(Go 标准库里唯一的无依赖位图字体 basicfont 只有
// 7x13 的拉丁字形),所以标签用国家码 + 次数,中文说明放在邮件正文里。
func RenderPNG(sum *Summary) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, mapWidth, mapHeight))
draw.Draw(img, img.Bounds(), &image.Uniform{colOcean}, image.Point{}, draw.Src)
lons, lats := graticule()
for _, lon := range lons {
x, _ := project(0, lon)
c := colGrid
if lon == 0 {
c = colGridKey
}
drawVLine(img, x, c)
}
for _, lat := range lats {
_, y := project(lat, 0)
c := colGrid
if lat == 0 {
c = colGridKey
}
drawHLine(img, y, c)
}
pts := bubbles(sum)
maxHits := maxCityHits(pts)
// 从小到大画:大气泡后画才能盖住小气泡,否则重要的点被长尾挡住。
for i := len(pts) - 1; i >= 0; i-- {
p := pts[i]
x, y := project(p.Lat, p.Lon)
r := bubbleRadius(p.Hits, maxHits)
fillCircle(img, x, y, r, colBubble, 0.35)
strokeCircle(img, x, y, r, colRim, 0.8)
}
// 刻度先画:气泡后画会盖住刻度,保证气泡在视觉上优先。
for _, lon := range tickLons {
x, _ := project(0, lon)
s := lonLabel(lon)
tx := x - basicfontWidth(s)/2
if tx < 2 {
tx = 2
}
if tx+basicfontWidth(s) > mapWidth-2 {
tx = mapWidth - 2 - basicfontWidth(s)
}
drawText(img, tx, mapHeight-15, s, colGridKey, false)
}
for _, lat := range tickLats {
_, y := project(lat, 0)
drawText(img, 3, y-7, latLabel(lat), colGridKey, false)
}
// 前 8 个城市标国家码 + 次数,重叠的跳过(理由见 labelPacker)。
packer := newLabelPacker(mapWidth, mapHeight)
for i, p := range pts {
if i >= 8 {
break
}
x, y := project(p.Lat, p.Lon)
r := bubbleRadius(p.Hits, maxHits)
label := fmt.Sprintf("%s %d", p.CountryCode, p.Hits)
tx, ty, ok := packer.place(x, y, r, basicfontWidth(label), 13)
if !ok {
continue
}
drawText(img, tx, ty, label, colText, true)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// basicfontWidth 7x13 位图字体的定宽推进值:每个字符正好 7px。
func basicfontWidth(s string) float64 { return float64(len(s)) * 7 }
// drawText 用 7x13 位图字体写一行 ASCII 文字,(x, top) 是文本框左上角。
//
// outline=true 时先描一圈白,避免文字压在气泡或网格上糊成一片。
func drawText(img *image.RGBA, x, top float64, s string, c color.RGBA, outline bool) {
dot := fixed.P(int(x), int(top)+11)
if outline {
for _, off := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
h := &font.Drawer{
Dst: img, Src: &image.Uniform{color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}},
Face: basicfont.Face7x13,
Dot: fixed.P(int(x)+off[0], int(top)+11+off[1]),
}
h.DrawString(s)
}
}
d := &font.Drawer{Dst: img, Src: &image.Uniform{c}, Face: basicfont.Face7x13, Dot: dot}
d.DrawString(s)
}
// fillCircle 画一个带 1px 抗锯齿边缘的实心圆(alpha 混合)。
func fillCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) {
drawCircleRing(img, cx, cy, 0, r, c, alpha)
}
// strokeCircle 画一个 1px 宽的圆环。
func strokeCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) {
drawCircleRing(img, cx, cy, r-0.5, r+0.5, c, alpha)
}
// drawCircleRing 填充内半径 inner 到外半径 outer 之间的环(inner=0 即实心圆)。
func drawCircleRing(img *image.RGBA, cx, cy, inner, outer float64, c color.RGBA, alpha float64) {
x0 := int(math.Floor(cx - outer - 1))
x1 := int(math.Ceil(cx + outer + 1))
y0 := int(math.Floor(cy - outer - 1))
y1 := int(math.Ceil(cy + outer + 1))
if x0 < 0 {
x0 = 0
}
if y0 < 0 {
y0 = 0
}
if x1 > img.Bounds().Dx() {
x1 = img.Bounds().Dx()
}
if y1 > img.Bounds().Dy() {
y1 = img.Bounds().Dy()
}
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
dx := float64(x) + 0.5 - cx
dy := float64(y) + 0.5 - cy
d := math.Hypot(dx, dy)
// 与圆环相交的覆盖率:边缘 1px 内线性过渡,起到抗锯齿作用。
cov := math.Min(outer-d, d-inner) + 0.5
if cov <= 0 {
continue
}
if cov > 1 {
cov = 1
}
blendPixel(img, x, y, c, alpha*cov)
}
}
}
// drawVLine / drawHLine 画 1px 网格线(半透明,不抢气泡的视觉权重)。
func drawVLine(img *image.RGBA, x float64, c color.RGBA) {
xi := int(math.Round(x))
if xi < 0 || xi >= img.Bounds().Dx() {
return
}
for y := 0; y < img.Bounds().Dy(); y++ {
blendPixel(img, xi, y, c, 0.9)
}
}
func drawHLine(img *image.RGBA, y float64, c color.RGBA) {
yi := int(math.Round(y))
if yi < 0 || yi >= img.Bounds().Dy() {
return
}
for x := 0; x < img.Bounds().Dx(); x++ {
blendPixel(img, x, yi, c, 0.9)
}
}
// blendPixel 把颜色以 alpha 混合到目标像素上。
func blendPixel(img *image.RGBA, x, y int, c color.RGBA, alpha float64) {
if alpha <= 0 {
return
}
if alpha > 1 {
alpha = 1
}
i := img.PixOffset(x, y)
dst := img.Pix[i : i+4 : i+4]
img.Pix[i+0] = uint8(float64(c.R)*alpha + float64(dst[0])*(1-alpha))
img.Pix[i+1] = uint8(float64(c.G)*alpha + float64(dst[1])*(1-alpha))
img.Pix[i+2] = uint8(float64(c.B)*alpha + float64(dst[2])*(1-alpha))
img.Pix[i+3] = 0xFF
}
+212
View File
@@ -0,0 +1,212 @@
// 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 report
import (
"bytes"
"image/png"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
)
// sampleSummary 构造一份含两国三城的汇总,供渲染测试复用。
func sampleSummary() *Summary {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
return Build(Input{
Period: period,
Sources: []*db.HoneypotSourceAgg{
{IP: "203.0.113.1", Hits: 60, Banned: 60, Creds: 5, Provider: "微软 Azure(云厂商)"},
{IP: "203.0.113.2", Hits: 30, Banned: 30, Creds: 2, Provider: "未识别(普通网络)"},
{IP: "198.51.100.9", Hits: 10, Banned: 10},
},
Paths: []*db.HoneypotPathStat{{Path: "/wp-login.php", Count: 70}},
Geo: map[string]geoip.Geo{
"203.0.113.1": {Country: "美国", CountryCode: "US", Region: "Virginia", City: "Ashburn", Lat: 39.04, Lon: -77.49, HasCoords: true, ISP: "Amazon", ASN: "AS14618"},
"203.0.113.2": {Country: "中国", CountryCode: "CN", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true, ISP: "联通"},
"198.51.100.9": {Country: "德国", CountryCode: "DE", Region: "Berlin", City: "Berlin", Lat: 52.5, Lon: 13.4, HasCoords: true},
},
Now: start,
})
}
// TestRenderPNG 覆盖位图地图:必须是合法 PNG、尺寸固定、非空白。
//
// 空白图是这里最容易出的问题:坐标算错时所有气泡会画到画布外,
// 得到的仍是一张「合法但全白」的 PNG,肉眼要打开附件才发现。
func TestRenderPNG(t *testing.T) {
sum := sampleSummary()
data, err := RenderPNG(sum)
require.NoError(t, err)
require.True(t, len(data) > 1024, "PNG 太小,怀疑是空白图")
img, err := png.Decode(bytes.NewReader(data))
require.NoError(t, err)
require.Equal(t, mapWidth, img.Bounds().Dx())
require.Equal(t, mapHeight, img.Bounds().Dy())
// 统计与背景色不同的像素:气泡、网格、文字都会贡献非背景像素。
bg := img.At(1, 1)
diff := 0
for y := 0; y < mapHeight; y += 4 {
for x := 0; x < mapWidth; x += 4 {
r1, g1, b1, _ := img.At(x, y).RGBA()
r2, g2, b2, _ := bg.RGBA()
if r1 != r2 || g1 != g2 || b1 != b2 {
diff++
}
}
}
require.Greater(t, diff, 500, "画面上几乎没有非背景像素,地图可能画空了")
}
// TestRenderPNGDropsBadCoords 覆盖越界坐标:不能画到 0,0 去。
func TestRenderPNGDropsBadCoords(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sum := Build(Input{
Period: period,
Sources: []*db.HoneypotSourceAgg{{IP: "203.0.113.1", Hits: 5}},
Geo: map[string]geoip.Geo{
// HasCoords=true 但坐标是 (0,0):IP 库对「只知道国家」的常见输出。
"203.0.113.1": {Country: "未知海域", CountryCode: "XX", Lat: 0, Lon: 0, HasCoords: true},
},
Now: start,
})
require.Empty(t, bubbles(sum), "(0,0) 必须被丢弃,否则会画到几内亚湾")
}
// TestRenderSVG 覆盖 SVG:每个有坐标的城市都应该有一个 circle。
func TestRenderSVG(t *testing.T) {
sum := sampleSummary()
svg := RenderSVG(sum)
require.Contains(t, svg, "<svg")
// 三个城市 → 三个气泡
require.Equal(t, 3, strings.Count(svg, "<circle"))
require.Contains(t, svg, "北京")
// 投影后的坐标要落在画布内(0..960 / 0..480),而不是 NaN 或负数。
require.Contains(t, svg, `cx="790.4"`) // 北京 116.4E → x≈790
// 不允许出现任何国界相关元素:自绘底图不画政治边界,避免画错。
require.NotContains(t, svg, "<path")
}
// TestRenderHTMLNoKey 覆盖「未配腾讯 key」:给出申请指引,且不能偷偷塞一个 key。
func TestRenderHTMLNoKey(t *testing.T) {
sum := sampleSummary()
html := RenderHTML(sum, HTMLOptions{})
require.Contains(t, html, "未配置腾讯地图 key")
require.Contains(t, html, "lbs.qq.com")
require.NotContains(t, html, "map.qq.com/api/gljs", "没有 key 时不应加载腾讯 SDK")
// 离线 SVG 必须始终存在
require.Contains(t, html, "<svg")
// 关键数字要出现在报告里
require.Contains(t, html, "100") // 总命中 60+30+10
}
// TestRenderHTMLWithKey 覆盖「配了 key」:加载腾讯 SDK 并带正确 key。
func TestRenderHTMLWithKey(t *testing.T) {
sum := sampleSummary()
html := RenderHTML(sum, HTMLOptions{TencentMapKey: "TEST-KEY-123"})
require.Contains(t, html, "map.qq.com/api/gljs")
require.Contains(t, html, "TEST-KEY-123")
// 合规:不能出现被禁止的境外底图(大小写无关地查一遍)。
lower := strings.ToLower(html)
for _, banned := range []string{"googleapis.com/maps", "mapbox", "openstreetmap", "bing.com/maps"} {
require.NotContains(t, lower, banned)
}
}
// TestRenderHTMLEmpty 覆盖空窗口:不发一堆空表格,给一句结论。
func TestRenderHTMLEmpty(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
sum := Build(Input{
Period: Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)},
Now: start,
})
html := RenderHTML(sum, HTMLOptions{})
require.Contains(t, html, "本期没有捕获到任何蜜罐命中")
require.NotContains(t, html, "<table>", "空报告不该渲染数据表")
}
// TestBubbleRadiusMonotonic 覆盖气泡缩放:命中多的必须更大,且线性增长不会
// 把长尾压得看不见(sqrt 缩放)。
func TestBubbleRadiusMonotonic(t *testing.T) {
small := bubbleRadius(1, 1000)
mid := bubbleRadius(100, 1000)
big := bubbleRadius(1000, 1000)
require.Less(t, small, mid)
require.Less(t, mid, big)
// 1/1000 的命中也要有可辨识的尺寸
require.Greater(t, small, 3.0)
require.Less(t, big, 21.0)
}
// TestLabelPackerNoOverlap 是「AU 305」那个 bug 的回归测试。
//
// 同一个都市圈的不同城市坐标只差 0.01°,两行标签会精确重叠,叠出一个
// 看起来像真实数字的假字符串(实测把 "AU 55" 与 "AU 30" 叠成了 "AU 305")。
// 读图的人不会怀疑「305 是从哪来的」,只会把它当结论 —— 所以宁可少标。
func TestLabelPackerNoOverlap(t *testing.T) {
p := newLabelPacker(mapWidth, mapHeight)
// 三个几乎重合的气泡:第一个标在右侧、第二个自动换到左侧(都清晰可读),
// 第三个两侧都被占就只能放弃 —— 关键是它们不能叠在一起。
for i := 0; i < 3; i++ {
x, y := project(-27.48+float64(i)*0.01, 153.02+float64(i)*0.01)
p.place(x, y, 10, basicfontWidth("AU 55"), 13)
}
require.Len(t, p.placed, 2, "三个重合的气泡最多只能放下两个不重叠的标签")
requireNoOverlap(t, p.placed)
// 远离的气泡必须都能标上
p2 := newLabelPacker(mapWidth, mapHeight)
require.True(t, mustPlace(t, p2, 100, 100))
require.True(t, mustPlace(t, p2, 400, 300))
require.Len(t, p2.placed, 2)
requireNoOverlap(t, p2.placed)
}
// requireNoOverlap 断言任意两个标签框都不相交 —— 这是标签层的唯一不变量。
func requireNoOverlap(t *testing.T, boxes []labelBox) {
t.Helper()
for i := 0; i < len(boxes); i++ {
for j := i + 1; j < len(boxes); j++ {
a, b := boxes[i], boxes[j]
overlap := a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
require.False(t, overlap, "标签 %d 与 %d 重叠:%v / %v", i, j, a, b)
}
}
}
// TestLabelPackerClampsToCanvas 靠右边缘的气泡要把标签放到左边,而不是画出画布。
func TestLabelPackerClampsToCanvas(t *testing.T) {
p := newLabelPacker(mapWidth, mapHeight)
w := basicfontWidth("AU 55")
// 气泡贴着右边缘:右侧放不下,应自动改到左侧
x, _, ok := p.place(mapWidth-12, 240, 10, w, 13)
require.True(t, ok)
require.LessOrEqual(t, x+w, float64(mapWidth-2))
// 两条边都放不下(画布外)应放弃
p2 := newLabelPacker(20, 20)
_, _, ok = p2.place(10, 10, 40, w, 13)
require.False(t, ok)
}
func mustPlace(t *testing.T, p *labelPacker, bx, by float64) bool {
t.Helper()
_, _, ok := p.place(bx, by, 10, basicfontWidth("US 90"), 13)
return ok
}
+115
View File
@@ -0,0 +1,115 @@
// 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 report 生成周期性安全状况汇总(周报 / 月报)。
//
// 它回答的问题与蜜罐告警不同:告警是「刚刚有人在你门口试钥匙」,报告是
// 「这一周一共有多少人在试、从哪些国家来、哪几个网段最执着、我们在拦什么」。
//
// 产物分三份,各有各的用途:
// - 邮件正文里的 PNG 气泡地图:邮件客户端不执行 JS、多数还会剥离内联 SVG,
// 只有位图能保证「打开就看得到」;
// - 附件 HTML:完整报告(排行表 + 内联 SVG 地图),离线可看;
// - 附件 HTML 里的腾讯地图:配了 key 才有,可缩放、可点开看明细。
//
// ⚠️ 地图的合规边界:本包自己画的地图**只画经纬网与气泡,不画任何国界**。
// 国界、台湾、南海诸岛的绘制必须符合国家标准,自绘底图极容易画错,
// 所以自绘图一律不含政治边界;需要真实底图时用腾讯地图(底图由持牌厂商提供)。
package report
import (
"fmt"
"time"
)
// 周期类型。
const (
KindWeek = "week"
KindMonth = "month"
)
// Period 一个报告周期,时间窗口为 [Start, End)。
//
// 左闭右开是刻意的:相邻两期既不能重叠(同一条记录算两次),也不能漏掉
// 恰好落在边界整点上的记录。
type Period struct {
Kind string
Key string
Start time.Time
End time.Time
}
// Label 返回人类可读的窗口描述,如 "2026-10-19 ~ 2026-10-25"。
func (p Period) Label() string {
// End 是开区间端点,展示时退一秒落回周期内的最后一天,避免出现
// 「10-19 ~ 10-26」这种让人以为多算了一天的区间。
last := p.End.Add(-time.Second)
if p.Kind == KindMonth {
return p.Start.Format("2006-01")
}
return fmt.Sprintf("%s ~ %s", p.Start.Format("2006-01-02"), last.Format("2006-01-02"))
}
// Title 返回报告标题用的中文描述。
func (p Period) Title() string {
if p.Kind == KindMonth {
return p.Start.Format("2006 年 1 月") + "安全状况月报"
}
_, week := p.End.Add(-time.Second).ISOWeek()
return fmt.Sprintf("%s 第 %d 周安全状况周报", p.Start.Format("2006 年 1 月"), week)
}
// Filename 返回报告文件名(不含扩展名)。
func (p Period) Filename() string {
if p.Kind == KindMonth {
return "security-month-" + p.Start.Format("2006-01")
}
return "security-week-" + p.Start.Format("2006-01-02")
}
// startOfWeek 返回 t 所在周的周一 00:00(本地时区)。
func startOfWeek(t time.Time) time.Time {
wd := int(t.Weekday()) // Sunday=0 … Saturday=6
if wd == 0 {
wd = 7
}
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location()).AddDate(0, 0, -(wd - 1))
}
// startOfMonth 返回 t 所在月的 1 日 00:00。
func startOfMonth(t time.Time) time.Time {
y, m, _ := t.Date()
return time.Date(y, m, 1, 0, 0, 0, 0, t.Location())
}
// PreviousWeek 返回 now 之前的**完整**一周。
//
// 口径是「最近一个已结束的自然周(周一起算)」:在周一凌晨跑,拿到的是上周;
// 在周三跑(补发),拿到的仍然是上周而不是「本周至今」——
// 报告必须覆盖完整周期,否则两次运行之间数字对不上。
func PreviousWeek(now time.Time) Period {
end := startOfWeek(now)
start := end.AddDate(0, 0, -7)
// 用周期内最后一天算 ISO 周号:直接用 end 会被算成下一周。
y, w := end.Add(-time.Second).ISOWeek()
return Period{
Kind: KindWeek,
Key: fmt.Sprintf("week:%04d-W%02d", y, w),
Start: start,
End: end,
}
}
// PreviousMonth 返回 now 之前的完整一个自然月。
func PreviousMonth(now time.Time) Period {
end := startOfMonth(now)
start := end.AddDate(0, -1, 0)
return Period{
Kind: KindMonth,
Key: "month:" + start.Format("2006-01"),
Start: start,
End: end,
}
}
+101
View File
@@ -0,0 +1,101 @@
// 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 report
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestPreviousWeek 覆盖周报窗口:[周一 00:00, 下周一 00:00),且与「现在是周几」无关。
func TestPreviousWeek(t *testing.T) {
loc := time.Local
cases := []struct {
name string
now time.Time
wantStart time.Time
wantEnd time.Time
}{
{
name: "周一凌晨(报告应在此时发出,覆盖上周)",
now: time.Date(2026, 10, 26, 1, 0, 0, 0, loc), // 周一
wantStart: time.Date(2026, 10, 19, 0, 0, 0, 0, loc),
wantEnd: time.Date(2026, 10, 26, 0, 0, 0, 0, loc),
},
{
name: "周三补发:仍然覆盖「上周」而不是本周至今",
now: time.Date(2026, 10, 28, 15, 30, 0, 0, loc), // 周三
wantStart: time.Date(2026, 10, 19, 0, 0, 0, 0, loc),
wantEnd: time.Date(2026, 10, 26, 0, 0, 0, 0, loc),
},
{
name: "周日晚间:还没到本周结束,仍覆盖上周",
now: time.Date(2026, 10, 25, 23, 59, 0, 0, loc), // 周日
wantStart: time.Date(2026, 10, 12, 0, 0, 0, 0, loc),
wantEnd: time.Date(2026, 10, 19, 0, 0, 0, 0, loc),
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := PreviousWeek(c.now)
require.Equal(t, c.wantStart, p.Start)
require.Equal(t, c.wantEnd, p.End)
// 窗口必须正好 7 天,且左闭右开
require.Equal(t, 7*24*time.Hour, p.End.Sub(p.Start))
})
}
}
// TestPreviousWeekKey 覆盖 ISO 周号,特别是跨年那一周。
func TestPreviousWeekKey(t *testing.T) {
loc := time.Local
// 2027-01-01 是周五,属于 ISO 2026 年第 53 周;
// 2026-12-28(周一)~ 2027-01-03(周日)正是这一周。
// 若用 end 去算周号会得到 2027-W01,导致这一周的台账键错位。
p := PreviousWeek(time.Date(2027, 1, 4, 9, 0, 0, 0, loc)) // 周一
require.Equal(t, "week:2026-W53", p.Key)
require.Equal(t, time.Date(2026, 12, 28, 0, 0, 0, 0, loc), p.Start)
// 跨年另一侧:2026-01-01 所在周是 2026-W01,前一周属于 2025-W52。
p2 := PreviousWeek(time.Date(2026, 1, 5, 9, 0, 0, 0, loc))
require.Equal(t, "week:2026-W01", p2.Key)
}
// TestPreviousMonth 覆盖月报窗口,含 31 天/30 天/2 月。
func TestPreviousMonth(t *testing.T) {
loc := time.Local
p := PreviousMonth(time.Date(2026, 11, 3, 10, 0, 0, 0, loc))
require.Equal(t, "month:2026-10", p.Key)
require.Equal(t, time.Date(2026, 10, 1, 0, 0, 0, 0, loc), p.Start)
require.Equal(t, time.Date(2026, 11, 1, 0, 0, 0, 0, loc), p.End)
// 闰年 2 月必须有 29 天,且不能因为「减一个月」跑到 3 月去。
p2 := PreviousMonth(time.Date(2024, 3, 1, 0, 30, 0, 0, loc))
require.Equal(t, "month:2024-02", p2.Key)
require.Equal(t, time.Date(2024, 2, 1, 0, 0, 0, 0, loc), p2.Start)
require.Equal(t, 29*24*time.Hour, p2.End.Sub(p2.Start))
// 5 月 31 日往前一个月不能变成「4 月 31 日」这种不存在的日期:
// 窗口是 [4-01, 5-01),不需要做任何日期加减。
p3 := PreviousMonth(time.Date(2026, 5, 31, 12, 0, 0, 0, loc))
require.Equal(t, time.Date(2026, 4, 1, 0, 0, 0, 0, loc), p3.Start)
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, loc), p3.End)
}
// TestPeriodLabel 覆盖展示用的窗口描述:右端点要退一天,别让人以为多算了一天。
func TestPeriodLabel(t *testing.T) {
loc := time.Local
p := PreviousWeek(time.Date(2026, 10, 26, 1, 0, 0, 0, loc))
require.Equal(t, "2026-10-19 ~ 2026-10-25", p.Label())
m := PreviousMonth(time.Date(2026, 11, 3, 10, 0, 0, 0, loc))
require.Equal(t, "2026-10", m.Label())
require.Equal(t, "security-month-2026-10", m.Filename())
}