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