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:
@@ -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 }
|
||||
Reference in New Issue
Block a user