feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。
新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
(一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
每次运行精修到不同 IP,配额花在长尾上。
报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。
存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)
配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。
顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
This commit is contained in:
@@ -0,0 +1,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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 "未知"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user