// 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 }