Files
tamabox/internal/security/report/geo.go
T
tamakyi d690355cdc 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 个渲染测试失败。
2026-10-30 16:40:44 +08:00

157 lines
4.8 KiB
Go

// 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
}