feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)

以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
This commit is contained in:
tamakyi
2026-10-30 16:40:44 +08:00
parent 08bbd17751
commit d690355cdc
33 changed files with 5198 additions and 1 deletions
+349
View File
@@ -0,0 +1,349 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package report
import (
"net"
"sort"
"strings"
"time"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
"github.com/tamakyi/TamaBox/internal/shared"
)
// 未知标签。用「未知」而不是空串:报告里空单元格会被读成「没有」,
// 而这里的真实含义是「查不到」,两件事不能混。
const (
unknownCountry = "未知"
unknownCode = "--"
)
// SourceRow 一行来源明细。
type SourceRow struct {
IP string
Hits int64
Banned int64
Creds int64
Country string
CountryCode string
Region string
City string
ISP string
ASN string
Provider string // cdncheck 标签(云厂商 / CDN / WAF / 普通网络)
Lat float64
Lon float64
HasCoords bool
GeoSource string
}
// CountryRow 国家级聚合。
type CountryRow struct {
Code string
Name string
Hits int64
Sources int
// Share 命中占比(0-1)。
Share float64
}
// CityRow 城市级聚合(带坐标,供地图使用)。
type CityRow struct {
Country string
CountryCode string
Region string
City string
Hits int64
Sources int
Lat float64
Lon float64
}
// PathRow 被探测路径聚合。
type PathRow struct {
Path string
Hits int64
}
// DayRow 每日命中数。
type DayRow struct {
Day time.Time
Hits int64
}
// Summary 一份完整的汇总。
type Summary struct {
Period Period
GeneratedAt time.Time
// 总量指标
TotalHits int64
PrevHits int64
UniqueSources int
UniqueCountry int
BannedHits int64
CredHits int64
// UnlocatedHits 是「查不到地理位置」的命中数。必须显式报出来 ——
// 一个覆盖率 60% 的地图看起来与 100% 的一模一样,读图的人会误判。
UnlocatedHits int64
// CloudSources 命中来自云厂商/CDN/WAF 的独立来源数(cdncheck 判定)。
// 与家宽肉鸡区分开:前者封 IP 基本没用(换一台机器就行),
// 后者封了会打在真实用户身上(同一个出口可能共享给整栋楼)。
CloudSources int
Countries []CountryRow
Cities []CityRow
Sources []SourceRow
Paths []PathRow
Days []DayRow
MaxDay int64
ResolverDesc string
// Empty 表示窗口内一次命中都没有 —— 报告仍然要发(「这周没人打」是个结论,
// 不是失败),但排版会换成一句明确的结论而不是一堆空表格。
Empty bool
}
// Input Build 的输入。
type Input struct {
Period Period
Sources []*db.HoneypotSourceAgg
Paths []*db.HoneypotPathStat
Timestamps []time.Time
Geo map[string]geoip.Geo // 键为 shared.LimitKey 的产物
PrevHits int64
ResolverDesc string
Now time.Time
}
// maxSourceRows 报告里最多列多少条来源明细。
//
// 不是技术限制而是阅读限制:一份列了两千行的报告没人会看完,
// 而尾部每行 1 次命中的长尾对判断趋势没有价值。
const maxSourceRows = 200
// Build 把原始聚合结果整理成可渲染的 Summary。
func Build(in Input) *Summary {
now := in.Now
if now.IsZero() {
now = time.Now()
}
sum := &Summary{
Period: in.Period,
GeneratedAt: now,
PrevHits: in.PrevHits,
ResolverDesc: in.ResolverDesc,
}
// ---- 1. 按 LimitKey 合并来源 ----
// 同一台机器在 /64 内轮换 IPv6 地址是很常见的规避手法,按精确地址统计
// 会得到几十条各 1 次的记录,看不出「其实是一个人」。
type merged struct {
row SourceRow
maxHits int64
}
byKey := make(map[string]*merged, len(in.Sources))
for _, s := range in.Sources {
key := shared.LimitKey(s.IP)
m, ok := byKey[key]
if !ok {
byKey[key] = &merged{
row: SourceRow{
IP: s.IP,
Hits: s.Hits,
Banned: s.Banned,
Creds: s.Creds,
Provider: s.Provider,
},
maxHits: s.Hits,
}
continue
}
m.row.Hits += s.Hits
m.row.Banned += s.Banned
m.row.Creds += s.Creds
if m.row.Provider == "" {
m.row.Provider = s.Provider
}
// 代表地址取命中最多的那个精确 IP:报告里展示的应该是最活跃的那一台。
if s.Hits > m.maxHits {
m.maxHits = s.Hits
m.row.IP = s.IP
}
}
// ---- 2. 挂上地理信息 ----
sources := make([]SourceRow, 0, len(byKey))
for key, m := range byKey {
row := m.row
if g, ok := in.Geo[key]; ok {
row.Country = g.Country
row.CountryCode = strings.ToUpper(g.CountryCode)
row.Region = g.Region
row.City = g.City
row.ISP = g.ISP
row.ASN = g.ASN
row.Lat, row.Lon = g.Lat, g.Lon
row.HasCoords = g.HasCoords
row.GeoSource = g.Source
}
if row.Country == "" {
row.Country = unknownCountry
}
if row.CountryCode == "" {
row.CountryCode = unknownCode
}
// 展示名在聚合层统一转换,下游(HTML / 邮件 / 地图提示)就都一致了。
// 港澳台在这里变成「中国香港 / 中国澳门 / 中国台湾」(理由见 countryName)。
row.Country = countryName(row.CountryCode, row.Country)
if !row.HasCoords {
sum.UnlocatedHits += row.Hits
}
if isCloudProvider(row.Provider) {
sum.CloudSources++
}
sum.TotalHits += row.Hits
sum.BannedHits += row.Banned
sum.CredHits += row.Creds
sources = append(sources, row)
}
sum.UniqueSources = len(sources)
sum.Empty = sum.TotalHits == 0
sort.Slice(sources, func(i, j int) bool {
if sources[i].Hits != sources[j].Hits {
return sources[i].Hits > sources[j].Hits
}
return sources[i].IP < sources[j].IP // 稳定排序,避免每次生成顺序抖动
})
if len(sources) > maxSourceRows {
sum.Sources = sources[:maxSourceRows]
} else {
sum.Sources = sources
}
// ---- 3. 国家聚合 ----
type cAgg struct {
name string
hits int64
sources int
}
countries := make(map[string]*cAgg)
for _, s := range sources {
c, ok := countries[s.CountryCode]
if !ok {
c = &cAgg{name: s.Country}
countries[s.CountryCode] = c
}
c.hits += s.Hits
c.sources++
}
for code, c := range countries {
share := 0.0
if sum.TotalHits > 0 {
share = float64(c.hits) / float64(sum.TotalHits)
}
sum.Countries = append(sum.Countries, CountryRow{
Code: code, Name: countryName(code, c.name), Hits: c.hits, Sources: c.sources, Share: share,
})
}
sum.UniqueCountry = len(sum.Countries)
sort.Slice(sum.Countries, func(i, j int) bool {
if sum.Countries[i].Hits != sum.Countries[j].Hits {
return sum.Countries[i].Hits > sum.Countries[j].Hits
}
return sum.Countries[i].Code < sum.Countries[j].Code
})
// ---- 4. 城市聚合(地图用,必须有坐标) ----
type cityAgg struct {
row CityRow
sources int
}
cities := make(map[string]*cityAgg)
for _, s := range sources {
if !s.HasCoords {
continue
}
// 同城不同区合并:地图上同一坐标画十几个重叠气泡没有意义。
key := s.CountryCode + "|" + s.Region + "|" + s.City
c, ok := cities[key]
if !ok {
cities[key] = &cityAgg{row: CityRow{
Country: s.Country, CountryCode: s.CountryCode,
Region: s.Region, City: s.City,
Hits: s.Hits, Sources: 1,
Lat: s.Lat, Lon: s.Lon,
}}
continue
}
c.row.Hits += s.Hits
c.row.Sources++
}
for _, c := range cities {
sum.Cities = append(sum.Cities, c.row)
}
sort.Slice(sum.Cities, func(i, j int) bool {
if sum.Cities[i].Hits != sum.Cities[j].Hits {
return sum.Cities[i].Hits > sum.Cities[j].Hits
}
return sum.Cities[i].City < sum.Cities[j].City
})
// ---- 5. 路径 ----
for _, p := range in.Paths {
sum.Paths = append(sum.Paths, PathRow{Path: p.Path, Hits: p.Count})
}
// ---- 6. 每日趋势 ----
// 先按本地日期把周期内每一天都填成 0,再累加 —— 没有命中的日子必须
// 显示为 0 而不是「不存在」,否则趋势图会把断档画成连续的。
dayIndex := make(map[string]int)
for d := in.Period.Start; d.Before(in.Period.End); d = d.AddDate(0, 0, 1) {
dayIndex[d.Format("2006-01-02")] = len(sum.Days)
sum.Days = append(sum.Days, DayRow{Day: d})
}
for _, ts := range in.Timestamps {
key := ts.In(in.Period.Start.Location()).Format("2006-01-02")
if i, ok := dayIndex[key]; ok {
sum.Days[i].Hits++
}
}
for _, d := range sum.Days {
if d.Hits > sum.MaxDay {
sum.MaxDay = d.Hits
}
}
return sum
}
// isCloudProvider 判断 cdncheck 的标签是否指出了一个云厂商/CDN/WAF。
// ipinfo.Lookup 未命中时返回「未识别(普通网络)」,失败时返回「识别失败…」——
// 这两种都算「非云」。
func isCloudProvider(label string) bool {
if label == "" {
return false
}
return !strings.HasPrefix(label, "未识别") && !strings.HasPrefix(label, "识别失败")
}
// Location 返回「国家 · 省 · 市」的展示串,缺失部分自动省略。
func (s SourceRow) Location() string {
parts := make([]string, 0, 3)
for _, v := range []string{s.Country, s.Region, s.City} {
if v != "" && v != unknownCountry {
parts = append(parts, v)
}
}
if len(parts) == 0 {
return "位置未知"
}
return strings.Join(parts, " · ")
}
// IsIPLiteral 报告该来源是否为可直接引用的 IP 字面量(用于 abuse 投诉模板)。
func (s SourceRow) IsIPLiteral() bool { return net.ParseIP(s.IP) != nil }