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