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
+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())
}