以周/月为单位汇总蜜罐命中,把来源 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 个渲染测试失败。
157 lines
4.8 KiB
Go
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
|
|
}
|