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