diff --git a/.workbuddy/memory/2026-10-29.md b/.workbuddy/memory/2026-10-29.md index 5dc696f..3b84f4c 100644 --- a/.workbuddy/memory/2026-10-29.md +++ b/.workbuddy/memory/2026-10-29.md @@ -297,3 +297,85 @@ gzip 实现质量高、后台移动端短标签方案做得细。 「代码 vs 线上」不一致,本轮未动,需用户确认是否要统一。 - **Windows Edit 又把 3 个 LF 文件转成 CRLF**(conf.go / users.go / form/user.go): 老坑复发,用 Python `b.replace(b'\r\n', b'\n')` 统一转回 LF 解决。 + +## R2 存储结构最终定论(用户贴出生产 app.ini [upload] 段后) + +生产 app.ini 关键段: +``` +[upload] +default_avatar = "https://r2.337845818.best/tamabox/tamabox_low.png" +default_qrbackgroundimage = "https://r2.337845818.best/tamabox/picture/2024/01/01.jpg" +image_endpoint = "https://.r2.cloudflarestorage.com/tamabox" # 注意带 /tamabox 后缀 +image_bucket = "tamabox" +image_bucket_cdn_host = "r2.337845818.best/tamabox" +``` + +实测铁证(GetObject 精确验证): +- 生产配置(endpoint 带 /tamabox + Bucket=tamabox + Key)下,`key=2025/12/1767082504316655685.jpg` + 返回 OK(187274),而 `key=picture/2024/01/01.jpg` 返回 404。 +- **结论:R2 bucket tamabox 的真实 key = `YYYY/MM/<17位雪花ID>.<原扩展名>`,无 picture/ 前缀、 + 无 tamabox/ 前缀。** +- 代码 `PictureKeyPrefix="picture/"` + `randstr.Hex(15)` 生成的 key 在 R2 里**根本不存在**。 +- `default_qrbackgroundimage` 里的 `picture/2024/01/01.jpg` 是**手动上传的站级静态资源**(孤立的), + 不是代码生成的,且该 key 实测 404(可能已失效)。 + +**重大结论:生产部署的存储层代码 ≠ mainline 仓库的存储层。** +mainline 的 `picture/YYYY/MM/DD/<15hex>`(无扩展名)是一套**从未上线、与生产脱节**的实现; +生产真实用的是 `YYYY/MM/<雪花ID>.<原扩展名>`(无前缀)。差异太大不可能是小改动, +是存储层被整体替换过但改动没合回 mainline。 + +对备份脚本的影响: +- 备份脚本用「无后缀 endpoint + pathStyle + Bucket=tamabox + prefix=tamabox/」List, + 返回的 key 带 tamabox/ 前缀(pathStyle 的 R2 行为),用同一 key GetObject 下载是自洽的、 + 能下全所有图片(含 picture/ 之外的真实 key)。 +- 但落盘路径带 tamabox/ 前缀,与「真实 key 无前缀」在语义上不一致——恢复回传时需注意。 +- 待用户决策:是否把 mainline 的 PictureKeyPrefix 和 key 生成逻辑改回生产真实格式 + (`YYYY/MM/<雪花ID>.`),还是维持现状。这是存储层正确性问题,未动。 + +## 存储层 key 口径对齐生产(已落地) + +用户确认「生产就是按 mainline 在 10-25 前最后一次修改部署的,有调整 git 查得到」,并拍板 +「先备份当前源码,再按生产口径统一代码」。 + +### 根因最终定论(纠正之前「雪花ID」的说法) + +- 生产 URL 里 `1767082504316655685` 不是雪花 ID,而是 **UnixNano 纳秒时间戳**(19 位): + `datetime.fromtimestamp(n/1e9)` = 2025-12-30 16:15:04,与路径 `2025/12` 完全吻合。 +- **生产口径 key = `YYYY/MM/.<原扩展名>`**(无前缀、无 day 层)。 +- 铁证:`route/question/page.go:uploadImage`(提问配图入口)**早就是这个格式**: + `uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)`。 + 脱节的只有 `internal/storage` 包里三个函数(UploadAvatarToS3 / UploadBackgroundToS3 / + UploadPictureToS3 及 UploadPictureToOSS),它们还在用老的 `picture/ 前缀 + YYYY/MM/DD + 15hex + 无扩展名`。 +- git 历史是**浅快照**:`PictureKeyPrefix` 只在 `e3e59e7 "chore: baseline snapshot"`(2026-09-10, + 却是「最早提交」)出现一次,之前之后都没改过 —— 完整历史在快照之外,10-25 前的改动追溯不到。 + +### 改动(internal/storage/) + +1. `picture.go`:`PictureKeyPrefix` 改 `""`;新增三个辅助函数: + - `uploadKey(ext)` → `YYYY/MM/`(唯一 key 生成点) + - `extFromContentType(contentType)` → MIME→扩展名映射(保证 key 扩展名与 ContentType 严格一致, + 避免「.jpg 实为 webp」错配) + - `originalExt(filename)` → 取原扩展名并小写(OSS 原图直传通道用) +2. `s3.go`:key 生成改 `uploadKey(extFromContentType(contentType))`;删除 day 变量与 randstr 依赖。 + 注意转换成功用 `.webp`(contentType=image/webp),回退原图用原格式扩展名(contentType=image/jpeg→.jpg)。 +3. `oss.go`:`UploadPictureToOSS` 的 `_ *multipart.FileHeader` 改 `fileHeader`(取原扩展名), + 原样上传不转 webp,key 用 `uploadKey(originalExt(...))`。 +4. 新增 `picture_test.go`:锁定 key 格式正则 `^\d{4}/\d{2}/\d{16,19}\.jpg$` + ext 映射 + originalExt。 + +### 验证 + +- gofmt 通过;`go build ./...` 通过;storage 全量测试 + 新增测试 PASS。 +- 行尾:三个文件都是 LF(工作区 LF),Edit 未破坏(HEAD 对比行数变化正常,非整文件重写)。 + +### 踩坑(本轮新) + +- **go 版本假象**:`go version` 报 1.27.3(GOROOT 指向 toolchain 目录),但 `GOTOOLCHAIN=local` + 时暴露真实工具链是 **1.25.5**;go.mod 要求 1.27.3。正确编译姿势:直接调已下载的 toolchain 二进制 + `$HOME/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.27.3.windows-amd64/bin/go.exe`, + 或去掉 GOSUMDB=off 让 toolchain 校验通过。用 `GOTOOLCHAIN=local` + 系统 go 会报 + `go.mod requires go >= 1.27.3 (running go 1.25.5)`。 +- **备份目录别放 .go 文件**:把 storage 源码备份到 `outputs/backup-*/` 后 `go build ./...` + 会把备份当 Go 包编译(undefined: announcementConfig 等)。备份的 .go 文件要改 `.go.bak` 后缀, + 或放到仓库外。 +- **fmt 的 `%s` 对 int64**:`fmt.Sprintf` 用 `%s` 拼 int64 能编译但 `go vet`(测试时自动跑)报 + `%s has arg of wrong type int64`,且语义模糊。纳秒时间戳必须用 `%d`。 diff --git a/.workbuddy/memory/2026-10-30.md b/.workbuddy/memory/2026-10-30.md new file mode 100644 index 0000000..4e4da2b --- /dev/null +++ b/.workbuddy/memory/2026-10-30.md @@ -0,0 +1,55 @@ +# 2026-10-30 + +## 蜜罐周期安全汇总(周报/月报 + 地图 + 邮件) + +用户需求:以周/月为单位汇总安全状况,把嗅探 IP 通过**公共服务**查地理位置,汇总到**地图**, +发到邮箱。起因是他发现线上有人隔一段时间换不同 IP 探测蜜罐。 + +### 关键认知:ipinfo 与 geoip 是两个维度(用户亲自纠正) + +用户反馈「ipinfo 只能查机房,肉鸡全显示『未识别(普通网络)』」。原因是 +`internal/security/ipinfo` 走 `projectdiscovery/cdncheck` 的离线 ASN 指纹,只认云厂商/CDN/WAF。 +它答的是「**是不是代理**」,不是「**在哪儿**」。肉鸡(家宽、企业宽带、移动网络)必然是后者, +所以安全汇总必须另建一套真正的 IP 地理库 → 新包 `internal/security/geoip`。 + +### 新增文件 + +- `internal/security/geoip/geoip.go` — ip-api 批量打底(100/批、1.4s 节流)+ 腾讯精修国内 + (只精修前 200 个,一 IP 一请求)。`Result{Geo, Unresolved, Requests, Failures}` 三态。 +- `internal/security/report/{period,aggregate,mapdraw,html,geo,countryname}.go` +- `internal/db/{ipgeo,security_report}.go` — `ip_geo_cache`(主键 = `LimitKey`)、 + `security_report_logs`(`period_key` 唯一索引保证幂等) +- `internal/cmd/report.go` — `honeypot report --period week|month` + 30 分钟一轮的调度器 +- `templates/mail/security-report.html` + +### 踩过的坑(都要记住) + +1. **ip-api 免费版很慢**:22 个 IP 实测 4–10 秒,且同一批重复请求更慢。最初设 15s 超时 → + 第一次真实数据就整批超时、定位率 0%。现在 `defaultTimeout = 45s` + 重试一次。 +2. **缓存毒化**:早先把所有 miss 都写缓存并 `Failed=true`(TTL 3 天)→ 一次网络抖动 + 等于把整批 IP 钉成「查不到」三天,报告定位率莫名变 0 且无任何线索。 + 现在只有服务**明确回答**没数据才允许写失败缓存;请求失败绝不写。 +3. **`addrs` 不能从 map 取顺序**:腾讯精修只取前 N 个,若按 map 迭代(随机)取, + 每次运行精修到的 IP 都不同,配额花在长尾上。必须按 `keys` 原始顺序(= 命中数降序)建。 +4. **地图标签会叠出假数字**:同一都市圈两个城市坐标只差 0.01°,标签精确重叠, + 「AU 55」+「AU 30」叠成了看起来像 **AU 305** 的字符串 —— 读图的人会当结论。 + 已加 `labelPacker`(不重叠才画,右侧放不下换左侧)。 +5. **不能无脑把腾讯结果的国家码写成 CN**:`provider=tencent` 时海外 IP 也走这条路。 + 现在从 `ad_info.nation` 精确判「中国」才给 CN。 + +### 合规(地图) + +自绘底图**零国界**,只画经纬网 + 气泡 + 度数刻度;真实底图交给腾讯地图(配 key 才有, +未配给申请指引)。代码里不预置任何 key,测试里有禁词断言(googleapis/mapbox/osm/bing)。 +报告里 HK/MO/TW 统一显示为「中国香港 / 中国澳门 / 中国台湾」(`countryname.go`), +且常见国家改用中文名(报告是给中文读者看的)。 + +### 端到端验证(WSL Debian + PG 17.11) + +854 条仿真数据 / 22 个来源:首跑 36s(真实 ip-api)、次跑 1.3s(缓存命中), +定位覆盖 22/22、14 个国家/地区、未能定位 0 次。 + +### 顺手修的(别人改动留下的) + +- `internal/route/honeypot_test.go` 的 `captureStore` 缺 3 个新方法 → 整个 route 包编译不过 +- `templates/render_test.go` 的 `mockLoggedUser` 缺 `TelegramChatID` → 3 个渲染测试失败 diff --git a/conf/app.sample.ini b/conf/app.sample.ini index 90f297e..806c876 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -195,6 +195,48 @@ honeypot_ban_minutes = 1440 ; 建议单独挂一个卷,并让它不参与备份。 honeypot_cred_dir = data/honeypot-creds +; ── 安全状况汇总报告(周报 / 月报)────────────────────────────── +; +; 把周期内的蜜罐命中按来源 IP 聚合,再通过公共 IP 库定位到城市, +; 生成「世界地图 + 国家/城市排行 + 来源明细」的 HTML 报告并邮件送达。 +; +; ⚠️ 与 cdncheck 离线指纹的区别:那个只认云厂商 / CDN / WAF 的 ASN, +; 肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。 +; 下面的 IP 库回答的是「这个 IP 在哪儿」,是另一个维度。 +; +; 数据源(ip_geo_provider): +; auto(缺省) = ip-api.com 批量打底(全球、无需 key)+ 腾讯精修国内 +; ipapi = 只用 ip-api.com +; tencent = 只用腾讯位置服务(必须配 key,否则降级 ipapi) +; none = 不查询任何外部服务,攻击者 IP 一个字节都不外发 +; 隐私边界:auto / ipapi 会把攻击者 IP 发给 ip-api.com,tencent 发给腾讯。 +ip_geo_provider = auto + +; 腾讯位置服务 key(lbs.qq.com 申请,个人免费额度足够)。 +; 两个用途:① 精修国内 IP 的省市与坐标;② 报告里可交互地图的底图。 +; 留空 → 跳过国内精修,报告里只保留离线地图(经纬网 + 气泡)。 +ip_geo_tencent_key = + +; 周期性安全汇总邮件。缺省全开。 +; 发送时机:周报在每周一、月报在每月 1 日之后的第一轮检查发出,覆盖 +; 「上一个完整周期」;服务停机错过时间窗会在重新上线后补发; +; 幂等由 security_report_logs 的 period_key 唯一索引保证,不会重复发。 +; 失败最多重试 5 次后停下(SMTP 配错时无限重试只会刷满日志), +; 之后可用 TamaBox honeypot report 手动补发。 +honeypot_report_enabled = true +honeypot_report_weekly = true +honeypot_report_monthly = true + +; 报告收件人。留空回退 security_alert_email。 +; 单独一个键是为了能把「随时发生的封禁告警」与「定期汇总」分投到不同邮箱 +; (例如告警进手机推送邮箱、汇总进归档邮箱)。 +honeypot_report_email = + +; 报告文件(HTML + PNG)的存放目录,按 0700 创建、文件 0600。 +; 里面有攻击者 IP 明细,不要放进 Web 可访问的目录。 +; 想先看内容不发送:TamaBox honeypot report --period week --no-email +honeypot_report_dir = data/reports + ; 被封禁(自动封禁 / 蜜罐封禁)时返回的图片路径,相对路径以进程工作目录为基准。 ; 留空、文件读不到、或内容不是图片 → 一律回退为纯文字 "403 Forbidden"。 ; 只接受 image/* 的内容(png / jpeg / gif / webp):指向 app.ini 之类会被拒绝, diff --git a/internal/cmd/honeypot.go b/internal/cmd/honeypot.go index 270a2f6..de1b6e0 100644 --- a/internal/cmd/honeypot.go +++ b/internal/cmd/honeypot.go @@ -25,6 +25,7 @@ import ( // TamaBox honeypot hits --since 24h 列出捕获到的破解凭据 // TamaBox honeypot stats --days 7 统计来源/路径/凭据排行 // TamaBox honeypot size 报告表行数与磁盘占用 +// TamaBox honeypot report --period week 生成安全汇总周报(含地图)并发邮件 // TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定) // // 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里, @@ -68,6 +69,8 @@ var Honeypot = &cli.Command{ Usage: "Report honeypot table row count and disk usage", Action: runHoneypotSize, }, + // 周期安全汇总:把命中按 IP 定位到城市,出地图报告并邮件送达。 + reportCommand, { Name: "paths", Usage: "List active decoy paths (built-in + honeypot_paths)", diff --git a/internal/cmd/report.go b/internal/cmd/report.go new file mode 100644 index 0000000..f08d5d0 --- /dev/null +++ b/internal/cmd/report.go @@ -0,0 +1,551 @@ +// 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 cmd + +import ( + "context" + "fmt" + "html" + "html/template" + "sort" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" + "gorm.io/gorm" + + "github.com/tamakyi/TamaBox/internal/conf" + "github.com/tamakyi/TamaBox/internal/db" + "github.com/tamakyi/TamaBox/internal/mail" + "github.com/tamakyi/TamaBox/internal/security/geoip" + "github.com/tamakyi/TamaBox/internal/security/report" + "github.com/tamakyi/TamaBox/internal/shared" +) + +// reportCommand 是 honeypot report 子命令。 +var reportCommand = &cli.Command{ + Name: "report", + Usage: "Generate a weekly/monthly security summary (map + email)", + Description: "统计周期内的蜜罐命中,把来源 IP 通过公共 IP 库定位到城市,\n" + + " 汇总成带世界地图的报告,并(可选)发送到 security_alert_email。", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "period", Value: "week", Usage: "week or month"}, + &cli.StringFlag{Name: "to", Usage: "Override recipient (default: honeypot_report_email / security_alert_email)"}, + &cli.StringFlag{Name: "dir", Usage: "Output directory (default: honeypot_report_dir)"}, + &cli.BoolFlag{Name: "no-email", Usage: "Only generate files, do not send email"}, + }, + Action: runHoneypotReport, +} + +// reportResult 一次生成的产物。 +type reportResult struct { + Summary *report.Summary + HTMLPath string + PNGPath string + To string + Sent bool +} + +// runHoneypotReport 手动生成/补发一份报告。 +// +// 存在的意义不只是「测一下能不能跑」:自动周报发出去以后,运维经常需要 +// 「把上周的再发我一份」或「先出一份看看内容对不对」——手动入口必须与 +// 自动入口走完全相同的代码路径,否则手动验证过的东西和线上跑的不是一回事。 +func runHoneypotReport(ctx *cli.Context) error { + database, err := openDatabase() + if err != nil { + return err + } + + now := time.Now() + var period report.Period + switch strings.ToLower(strings.TrimSpace(ctx.String("period"))) { + case "week", "w", "": + period = report.PreviousWeek(now) + case "month", "m": + period = report.PreviousMonth(now) + default: + return errors.Errorf("--period 只能是 week 或 month(收到 %q)", ctx.String("period")) + } + + dir := ctx.String("dir") + if dir == "" { + dir = conf.Security.HoneypotReportDir + } + + res, err := buildSecurityReport(context.Background(), database, period, dir) + if err != nil { + return err + } + printSecurityReport(res) + + if ctx.Bool("no-email") { + fmt.Println() + fmt.Printf("已生成(未发信):\n %s\n %s\n", res.HTMLPath, res.PNGPath) + return nil + } + + to := strings.TrimSpace(ctx.String("to")) + if to == "" { + to = reportRecipient() + } + if to == "" { + return errors.New("未配置收件人:请在 [security] 设置 honeypot_report_email 或 security_alert_email,或用 --to 指定") + } + + if err := sendSecurityReport(res, to); err != nil { + return err + } + fmt.Printf("\n已发送到 %s\n", to) + return nil +} + +// reportRecipient 返回报告收件人。 +// +// 优先 honeypot_report_email,回退 security_alert_email —— 后者早就存在, +// 不给已有部署增加必填项;想分开投递的人再单独配前者。 +func reportRecipient() string { + if v := strings.TrimSpace(conf.Security.HoneypotReportEmail); v != "" { + return v + } + return strings.TrimSpace(conf.Security.SecurityAlertEmail) +} + +// buildSecurityReport 跑完整的「读数据 → 定位 → 聚合 → 落盘」流程。 +func buildSecurityReport(cctx context.Context, database *gorm.DB, period report.Period, dir string) (*reportResult, error) { + store := db.NewHoneypotStore(database) + + sources, err := store.AggregateSources(cctx, period.Start, period.End) + if err != nil { + return nil, errors.Wrap(err, "聚合蜜罐来源") + } + paths, err := store.AggregatePaths(cctx, period.Start, period.End, 20) + if err != nil { + return nil, errors.Wrap(err, "聚合被探测路径") + } + stamps, err := store.TimestampsBetween(cctx, period.Start, period.End) + if err != nil { + return nil, errors.Wrap(err, "读取命中时间戳") + } + + // 上一周期同为等长窗口,用于「较上期 ↑/↓」。 + prevHits, err := countBetween(cctx, store, period.Start.Add(-period.End.Sub(period.Start)), period.Start) + if err != nil { + return nil, errors.Wrap(err, "统计上一周期") + } + + // 先按 LimitKey 合并,再只对合并后的键做定位 —— 顺序按命中数降序, + // 因为腾讯精修有配额上限,要优先花在最活跃的来源上。 + keys := mergeKeysByHits(sources) + + resolver := geoip.New(geoip.Options{ + Provider: conf.Security.IPGeoProvider, + TencentKey: conf.Security.IPGeoTencentKey, + }) + geo := report.ResolveWithCache(cctx, db.NewIPGeoStore(database), resolver, keys) + + sum := report.Build(report.Input{ + Period: period, + Sources: sources, + Paths: paths, + Timestamps: stamps, + Geo: geo, + PrevHits: prevHits, + ResolverDesc: resolver.Describe(), + Now: time.Now(), + }) + + htmlPath, pngPath, err := report.WriteFiles(sum, dir, report.HTMLOptions{ + // 报告里的可交互地图用同一个 key(腾讯位置服务)。 + TencentMapKey: conf.Security.IPGeoTencentKey, + }) + if err != nil { + return nil, errors.Wrap(err, "写报告文件") + } + + return &reportResult{Summary: sum, HTMLPath: htmlPath, PNGPath: pngPath}, nil +} + +// countBetween 统计 [from, to) 的命中数。 +func countBetween(cctx context.Context, store db.HoneypotStore, from, to time.Time) (int64, error) { + after, err := store.CountSince(cctx, from) + if err != nil { + return 0, err + } + upTo, err := store.CountSince(cctx, to) + if err != nil { + return 0, err + } + // CountSince 是「>= since」,两次相减即区间内数量; + // 时钟回拨等异常情况下可能出现负数,钳到 0。 + if after < upTo { + return 0, nil + } + return after - upTo, nil +} + +// mergeKeysByHits 把来源按封禁口径(LimitKey)合并,返回按命中数降序的键。 +func mergeKeysByHits(sources []*db.HoneypotSourceAgg) []string { + hits := make(map[string]int64, len(sources)) + for _, s := range sources { + hits[shared.LimitKey(s.IP)] += s.Hits + } + keys := make([]string, 0, len(hits)) + for k := range hits { + keys = append(keys, k) + } + // 按命中数降序;同次数按键名升序保证顺序稳定 —— 否则每次跑报告时 + // 「腾讯精修覆盖的那 N 个 IP」会随机漂移,两个周期的数据不可比。 + sort.Slice(keys, func(i, j int) bool { + if hits[keys[i]] != hits[keys[j]] { + return hits[keys[i]] > hits[keys[j]] + } + return keys[i] < keys[j] + }) + return keys +} + +// sendSecurityReport 发信(内嵌 PNG 地图 + 附 HTML 报告)。 +func sendSecurityReport(res *reportResult, to string) error { + sum := res.Summary + payload := mail.SecurityReportMail{ + To: to, + Title: fmt.Sprintf("【狼的提问箱】%s", sum.Period.Title()), + Period: sum.Period.Label(), + Generated: sum.GeneratedAt.Format("2006-01-02 15:04"), + SummaryHTML: template.HTML(buildSummaryHTML(sum)), + CountriesHTML: template.HTML(buildCountriesHTML(sum)), + Notes: template.HTML(buildNotesHTML(sum)), + Attachments: []mail.MailAttachment{{ + Path: res.HTMLPath, + Name: sum.Period.Filename() + ".html", + }}, + } + + // 内嵌地图:只有真的有命中才发(无数据时地图是一张空白经纬网,没有信息量)。 + if !sum.Empty && len(sum.Cities) > 0 { + payload.MapCID = "honeypot-map" + payload.InlineImages = []mail.MailInlineImage{{ + Path: res.PNGPath, + CID: "honeypot-map", + }} + } + + if err := mail.SendSecurityReportMail(payload); err != nil { + return errors.Wrap(err, "发送报告邮件") + } + res.Sent = true + res.To = to + return nil +} + +// printSecurityReport 在终端打印报告摘要(手动运行时看)。 +func printSecurityReport(res *reportResult) { + sum := res.Summary + fmt.Println(sum.Period.Title()) + fmt.Println(strings.Repeat("-", 72)) + fmt.Printf(" 窗口:%s\n", sum.Period.Label()) + fmt.Printf(" 命中:%d 次(上期 %d)\n", sum.TotalHits, sum.PrevHits) + fmt.Printf(" 独立来源:%d 个,覆盖 %d 个国家/地区\n", sum.UniqueSources, sum.UniqueCountry) + fmt.Printf(" 真实破解尝试:%d 次;已拦截:%d 次\n", sum.CredHits, sum.BannedHits) + if sum.UnlocatedHits > 0 { + fmt.Printf(" 未能定位:%d 次命中(%s)\n", sum.UnlocatedHits, pctOf(sum.UnlocatedHits, sum.TotalHits)) + } + if len(sum.Countries) > 0 { + fmt.Println(" 主要来源:") + for i, c := range sum.Countries { + if i >= 8 { + break + } + fmt.Printf(" %-4s %s %6d 次 %s\n", c.Code, padDisplay(truncateForCLI(c.Name, 12), 20), c.Hits, pctOf(c.Hits, sum.TotalHits)) + } + } + fmt.Println() +} + +// ---- 邮件正文片段 ---- + +// buildSummaryHTML 生成 KPI 卡片(邮件只能用内联样式,表格布局最稳)。 +func buildSummaryHTML(sum *report.Summary) string { + kpis := [][2]string{ + {"蜜罐命中", fmt.Sprintf("%d 次", sum.TotalHits)}, + {"独立来源", fmt.Sprintf("%d 个", sum.UniqueSources)}, + {"涉及国家/地区", fmt.Sprintf("%d 个", sum.UniqueCountry)}, + {"真实破解尝试", fmt.Sprintf("%d 次", sum.CredHits)}, + {"已拦截", fmt.Sprintf("%d 次", sum.BannedHits)}, + {"云主机来源", fmt.Sprintf("%d 个", sum.CloudSources)}, + } + var b strings.Builder + b.WriteString(``) + for i := 0; i < len(kpis); i += 2 { + b.WriteString(``) + for j := i; j < i+2 && j < len(kpis); j++ { + b.WriteString(``) + } + if i+1 >= len(kpis) { + b.WriteString(``) + } + b.WriteString(``) + } + b.WriteString(`
`) + b.WriteString(`
`) + fmt.Fprintf(&b, `
%s
`, kpis[j][0]) + fmt.Fprintf(&b, `
%s
`, kpis[j][1]) + b.WriteString(`
`) + + if sum.PrevHits > 0 { + delta := float64(sum.TotalHits-sum.PrevHits) / float64(sum.PrevHits) * 100 + arrow := "上升" + if delta < 0 { + arrow = "下降" + } + fmt.Fprintf(&b, `
较上一周期%s %.0f%%(上期 %d 次)。
`, + arrow, absFloat(delta), sum.PrevHits) + } + return b.String() +} + +// buildCountriesHTML 生成国家排行表。 +func buildCountriesHTML(sum *report.Summary) string { + if len(sum.Countries) == 0 { + return "" + } + var b strings.Builder + b.WriteString(`
来源国家 / 地区(前 10)
`) + b.WriteString(``) + b.WriteString(``) + for _, h := range []string{"国家/地区", "命中", "占比"} { + align := "left" + if h != "国家/地区" { + align = "right" + } + fmt.Fprintf(&b, ``, + align, align, h) + } + b.WriteString(``) + for i, c := range sum.Countries { + if i >= 10 { + break + } + name := c.Name + if c.Code != "" && c.Code != "--" { + name = fmt.Sprintf("%s(%s)", c.Name, c.Code) + } + fmt.Fprintf(&b, ``, + html.EscapeString(name)) + fmt.Fprintf(&b, ``, c.Hits) + fmt.Fprintf(&b, ``, + html.EscapeString(pctOf(c.Hits, sum.TotalHits))) + } + b.WriteString(`
%s
%s%d%s
`) + return b.String() +} + +// buildNotesHTML 生成口径说明。 +func buildNotesHTML(sum *report.Summary) string { + lines := []string{ + fmt.Sprintf("地理定位数据源:%s。", sum.ResolverDesc), + fmt.Sprintf("未能定位的命中:%d 次(占 %s)。", sum.UnlocatedHits, pctOf(sum.UnlocatedHits, sum.TotalHits)), + "「真实破解尝试」指提交了账号密码字段的请求;捕获到的账号密码不写进报告,只在服务器取证目录中(TamaBox honeypot hits 查看)。", + "「网络归属」来自离线指纹,判定云厂商 / CDN / WAF,与地理位置是两个维度:显示「未识别(普通网络)」不代表没定位到位置。", + } + var b strings.Builder + for _, l := range lines { + fmt.Fprintf(&b, `%s
`, html.EscapeString(l)) + } + return b.String() +} + +func pctOf(v, total int64) string { + if total <= 0 { + return "—" + } + return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100) +} + +func absFloat(f float64) float64 { + if f < 0 { + return -f + } + return f +} + +// ---- 后台调度 ---- + +// SecurityReportScheduler 周期性检查是否有到期的周报/月报需要发送。 +// +// 为什么不直接用 cron:「这一周的周报发过没」需要幂等保证,而幂等只能靠 +// 数据库里的台账(security_report_logs 的 period_key 唯一索引)。有了台账, +// 调度器只需要「每半小时看一眼现在该发哪一期、发过没」—— +// 服务中途重启、周一维护窗口错过、机器睡了一觉再醒来,都能正确补发。 +type SecurityReportScheduler struct { + database *gorm.DB + interval time.Duration + // now 可注入(测试用)。 + now func() time.Time +} + +// NewSecurityReportScheduler 构造调度器。 +func NewSecurityReportScheduler(database *gorm.DB) *SecurityReportScheduler { + return &SecurityReportScheduler{ + database: database, + // 30 分钟一轮:周报/月报的时效要求是「周一/月初能看到」, + // 半小时的粒度远够,而且这个检查只读一条台账,成本几乎为零。 + interval: 30 * time.Minute, + now: time.Now, + } +} + +// Start 启动后台循环,stop 关闭时退出。 +func (s *SecurityReportScheduler) Start(stop <-chan struct{}) { + if s == nil || s.database == nil { + return + } + go func() { + // 启动后先等 2 分钟再跑第一轮:让站点先完成启动、DB 连接池预热, + // 也避免与保留期清理抢同一时刻的数据库资源。 + select { + case <-stop: + return + case <-time.After(2 * time.Minute): + } + s.tick() + t := time.NewTicker(s.interval) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + s.tick() + } + } + }() + logrus.WithField("interval", s.interval).Info("安全汇总报告调度器已启动") +} + +// tick 检查并发送到期报告。 +func (s *SecurityReportScheduler) tick() { + now := s.now() + periods := make([]report.Period, 0, 2) + if conf.Security.HoneypotReportWeekly { + periods = append(periods, report.PreviousWeek(now)) + } + if conf.Security.HoneypotReportMonthly { + periods = append(periods, report.PreviousMonth(now)) + } + + for _, p := range periods { + if !s.due(p, now) { + continue + } + if err := s.deliver(p, now); err != nil { + logrus.WithError(err).WithField("period", p.Key).Warn("安全汇总报告发送失败") + } + } +} + +// due 判断某周期现在是否应该发送。 +// +// 条件:周期已结束超过 1 小时(留出跨零点写入的余量),且尚未成功发送。 +// 不限制「过期多久就不发」:一台停机两周的机器重新上线时,补发上周的周报 +// 仍然有意义 —— 反倒是「因为晚了就永远不发」会造成数据黑洞。 +func (s *SecurityReportScheduler) due(p report.Period, now time.Time) bool { + if now.Before(p.End) { + return false + } + rec, err := db.NewSecurityReportStore(s.database).Get(context.Background(), p.Key) + if err != nil { + logrus.WithError(err).WithField("period", p.Key).Warn("读取报告台账失败,本轮跳过") + return false + } + if rec == nil { + return true + } + if rec.Sent { + return false + } + // 失败重试上限:SMTP 配错时每次 tick 都失败,无限重试只会把日志和邮箱 + // 配额消耗干净。5 次之后停下来,等人工介入(CLI 手动补发会重置计数)。 + const maxAttempts = 5 + return rec.Attempts < maxAttempts +} + +// deliver 生成并发送一份报告,同时写台账。 +func (s *SecurityReportScheduler) deliver(p report.Period, now time.Time) error { + store := db.NewSecurityReportStore(s.database) + + // 先读一次台账(可能为 nil),用于累计尝试次数。 + rec, err := store.Get(context.Background(), p.Key) + if err != nil { + return errors.Wrap(err, "读取台账") + } + if rec == nil { + rec = &db.SecurityReportLog{PeriodKey: p.Key, Kind: p.Kind} + } + rec.WindowStart, rec.WindowEnd = p.Start, p.End + rec.Attempts++ + + to := reportRecipient() + if to == "" { + rec.Error = "未配置收件人(honeypot_report_email / security_alert_email 均为空)" + _ = store.Save(context.Background(), rec) + return errors.New(rec.Error) + } + rec.Recipient = to + + res, err := buildSecurityReport(context.Background(), s.database, p, conf.Security.HoneypotReportDir) + if err != nil { + rec.Error = truncateForCLI(err.Error(), 480) + _ = store.Save(context.Background(), rec) + return err + } + + rec.Hits = res.Summary.TotalHits + rec.UniqueSources = int64(res.Summary.UniqueSources) + rec.Banned = res.Summary.BannedHits + rec.Countries = res.Summary.UniqueCountry + rec.FilePath = res.HTMLPath + + if err := sendSecurityReport(res, to); err != nil { + rec.Error = truncateForCLI(err.Error(), 480) + _ = store.Save(context.Background(), rec) + return err + } + rec.Sent = true + rec.Error = "" + if err := store.Save(context.Background(), rec); err != nil { + // 台账写失败意味着下一轮会重复发送。这里必须大声报出来, + // 否则用户会收到同一周的周报两三次而不知道原因。 + logrus.WithError(err).WithField("period", p.Key). + Error("报告已发送但台账写入失败:下一轮可能重复发送同一期报告") + return nil + } + logrus.WithFields(logrus.Fields{ + "period": p.Key, "hits": rec.Hits, "to": to, "file": rec.FilePath, + }).Info("安全汇总报告已发送") + return nil +} + +// padDisplay 按「显示列宽」右补空格,让 CLI 表格对齐。 +// +// 不能用 %-20s:Go 的宽度按字符数算,一个汉字只算 1,终端里却占两格 —— +// 国家名改成中文之后,表格会歪得看不出哪一列是哪一列。 +func padDisplay(s string, width int) string { + w := 0 + for _, r := range s { + if r > 0x2E80 { + w += 2 + } else { + w++ + } + } + if w >= width { + return s + } + return s + strings.Repeat(" ", width-w) +} diff --git a/internal/cmd/report_test.go b/internal/cmd/report_test.go new file mode 100644 index 0000000..df2a0eb --- /dev/null +++ b/internal/cmd/report_test.go @@ -0,0 +1,103 @@ +// 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 cmd + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/tamakyi/TamaBox/internal/db" + "github.com/tamakyi/TamaBox/internal/dbutil" + "github.com/tamakyi/TamaBox/internal/security/report" +) + +// TestMergeKeysByHits 覆盖「按封禁口径合并后再定位」的键生成。 +// +// 顺序很重要:腾讯精修有配额上限,只处理前 N 个 —— 必须优先给最活跃的来源, +// 否则配额会花在只命中一次的长尾地址上,而报告的主要来源反而定位不到。 +func TestMergeKeysByHits(t *testing.T) { + rows := []*db.HoneypotSourceAgg{ + {IP: "2408:8207:1234:5678::1", Hits: 5}, + {IP: "2408:8207:1234:5678::2", Hits: 3}, // 同 /64 → 合成 8 + {IP: "203.0.113.7", Hits: 6}, + {IP: "198.51.100.1", Hits: 1}, + } + + got := mergeKeysByHits(rows) + require.Len(t, got, 3) + // 8 次(合并后的 /64)> 6 次 > 1 次 + require.Equal(t, "2408:8207:1234:5678::/64", got[0]) + require.Equal(t, "203.0.113.7", got[1]) + require.Equal(t, "198.51.100.1", got[2]) +} + +// TestMergeKeysByHitsStable 覆盖同命中数时的稳定排序。 +// +// 不稳定的话,每次跑报告被腾讯精修覆盖的那批 IP 会随机漂移, +// 两个周期的定位精度就不一致了,对比起来像是数据变了。 +func TestMergeKeysByHitsStable(t *testing.T) { + rows := []*db.HoneypotSourceAgg{ + {IP: "203.0.113.9", Hits: 2}, + {IP: "203.0.113.2", Hits: 2}, + {IP: "203.0.113.5", Hits: 2}, + } + first := mergeKeysByHits(rows) + for i := 0; i < 20; i++ { + require.Equal(t, first, mergeKeysByHits(rows), "同命中数时顺序必须稳定") + } +} + +// TestSchedulerDue 覆盖周报/月报的发送判定,这是「不重复发、不永远不发」的核心。 +func TestSchedulerDue(t *testing.T) { + database, cleanup := dbutil.NewTestDB(t, db.AllTables...) + t.Cleanup(func() { + if err := cleanup("security_report_logs"); err != nil { + t.Fatal(err) + } + }) + + s := NewSecurityReportScheduler(database) + ctx := context.Background() + loc := time.Local + + // now = 2026-10-26(周一)02:00,上一周是 10-19 ~ 10-25 + now := time.Date(2026, 10, 26, 2, 0, 0, 0, loc) + period := report.PreviousWeek(now) + require.Equal(t, "week:2026-W43", period.Key) + + // 1) 台账里没有记录 → 该发 + require.True(t, s.due(period, now)) + + // 2) 已成功发送 → 不再发(哪怕重启一百次) + store := db.NewSecurityReportStore(database) + require.NoError(t, store.Save(ctx, &db.SecurityReportLog{ + PeriodKey: period.Key, Kind: period.Kind, + WindowStart: period.Start, WindowEnd: period.End, Sent: true, Attempts: 1, + })) + require.False(t, s.due(period, now)) + + // 3) 发送失败但未超重试上限 → 继续重试 + require.NoError(t, store.Save(ctx, &db.SecurityReportLog{ + PeriodKey: period.Key, Kind: period.Kind, + WindowStart: period.Start, WindowEnd: period.End, + Sent: false, Attempts: 3, Error: "smtp auth failed", + })) + require.True(t, s.due(period, now)) + + // 4) 超过重试上限 → 停下来等人工介入,避免刷爆日志与邮箱配额 + require.NoError(t, store.Save(ctx, &db.SecurityReportLog{ + PeriodKey: period.Key, Kind: period.Kind, + WindowStart: period.Start, WindowEnd: period.End, + Sent: false, Attempts: 5, Error: "smtp auth failed", + })) + require.False(t, s.due(period, now)) + + // 5) 周期还没结束 → 不发(哪怕台账里没有记录) + future := report.PreviousWeek(now.AddDate(0, 0, 7)) + require.False(t, s.due(future, now), "窗口未结束时不能发不完整的报告") +} diff --git a/internal/cmd/web.go b/internal/cmd/web.go index a3d6dd0..3074e3e 100644 --- a/internal/cmd/web.go +++ b/internal/cmd/web.go @@ -95,7 +95,11 @@ func runWeb(ctx *cli.Context) error { // 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。 // 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件, // 与 route 层那个 Append 用的句柄不会争抢同一天的文件。 + // 两个后台任务(保留期清理 / 周期报告)共用一个停止信号, + // defer 必须放在 if 外面 —— 否则「自动清理关掉、报告开着」时这个 + // channel 永远不会被关闭,那个 goroutine 就一直挂着。 stopRetention := make(chan struct{}) + defer close(stopRetention) if conf.Security.HoneypotAutoClean { var credPurge db.CredPurgeable if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil { @@ -104,7 +108,16 @@ func runWeb(ctx *cli.Context) error { credPurge = cs } db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention) - defer close(stopRetention) + } + + // 周期安全汇总(周报 / 月报):把命中按 IP 定位到城市,出地图报告并邮件送达。 + // 幂等靠 security_report_logs 的 period_key 唯一索引,所以这里只管定期看一眼 + // 「该发的那期发过没」—— 重启、错过时间窗都能正确补发,也不会重复发。 + if conf.Security.HoneypotReportEnabled { + if reportRecipient() == "" { + logrus.Warn("已启用安全汇总报告,但 honeypot_report_email 与 security_alert_email 都为空,将无法送达") + } + NewSecurityReportScheduler(gdb).Start(stopRetention) } // Redis 配了就必须连得上:session 建在它上面,宕机等于全站 500(含匿名访客)。 diff --git a/internal/conf/honeypot_defaults.go b/internal/conf/honeypot_defaults.go index b273139..4c2868c 100644 --- a/internal/conf/honeypot_defaults.go +++ b/internal/conf/honeypot_defaults.go @@ -43,6 +43,13 @@ const ( // // 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。 DefaultHoneypotCredDir = "data/honeypot-creds" + // DefaultIPGeoProvider 默认的地理定位数据源:auto(ip-api 打底 + 腾讯精修)。 + DefaultIPGeoProvider = "auto" + // DefaultHoneypotReportDir 安全汇总报告文件的默认目录。 + // + // 与凭据目录分开:报告里有攻击者 IP 明细,但它是可以外发/归档的材料; + // 凭据目录里是明文账号密码,永不外发。放在一起容易误打包。 + DefaultHoneypotReportDir = "data/reports" ) // sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。 @@ -109,4 +116,25 @@ func sanitizeHoneypot() { if strings.TrimSpace(Security.HoneypotCredDir) == "" { Security.HoneypotCredDir = DefaultHoneypotCredDir } + + // 地理定位数据源:未配置 → auto。 + // 显式写了不认识的值时保持原样,由 geoip 层解析并告警 —— + // 在配置层悄悄改成 auto 会让人以为配置生效了。 + if strings.TrimSpace(Security.IPGeoProvider) == "" { + Security.IPGeoProvider = DefaultIPGeoProvider + } + + // 周期报告:三个开关都用 HasKey 判定缺省值(同 HoneypotAutoClean 的理由)。 + if !sec.HasKey("honeypot_report_enabled") { + Security.HoneypotReportEnabled = true + } + if !sec.HasKey("honeypot_report_weekly") { + Security.HoneypotReportWeekly = true + } + if !sec.HasKey("honeypot_report_monthly") { + Security.HoneypotReportMonthly = true + } + if strings.TrimSpace(Security.HoneypotReportDir) == "" { + Security.HoneypotReportDir = DefaultHoneypotReportDir + } } diff --git a/internal/conf/keynorm.go b/internal/conf/keynorm.go index 80624c2..c125eed 100644 --- a/internal/conf/keynorm.go +++ b/internal/conf/keynorm.go @@ -50,6 +50,18 @@ var keyAliases = map[string]string{ "honeypot_retentionday": "honeypot_retain_days", "honeypot_keep_days": "honeypot_retain_days", "honeypot_retain_max_days": "honeypot_retain_days", + // 地理定位与周期报告:这几个键都是新增的,最容易被写成不同的词。 + "ipgeo_provider": "ip_geo_provider", + "ip_geolocation_provider": "ip_geo_provider", + "geo_provider": "ip_geo_provider", + "ip_geo_key": "ip_geo_tencent_key", + "ip_geo_tencent_apikey": "ip_geo_tencent_key", + "tencent_map_key": "ip_geo_tencent_key", + "honeypot_report": "honeypot_report_enabled", + "honeypot_report_mail": "honeypot_report_email", + "honeypot_report_to": "honeypot_report_email", + "honeypot_reports_dir": "honeypot_report_dir", + "honeypot_report_path": "honeypot_report_dir", } // normalizeKeyName 归一化配置键名。 diff --git a/internal/conf/static.go b/internal/conf/static.go index 17f2956..b7f4ab5 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -152,6 +152,42 @@ var ( // CSPReportURI 违规上报地址(可选)。留空时浏览器只把违规打到 console 不上报; // 用 report 模式观察时应当配上,否则「观察一周」就只是空谈。 CSPReportURI string `ini:"csp_report_uri"` + + // IPGeoProvider 蜜罐/扫描来源 IP 的地理定位数据源。 + // + // ⚠️ 与 ipinfo.Lookup(cdncheck 离线指纹)是两件事:那套只认云厂商/CDN/WAF + // 的 ASN,肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。 + // 这里回答的是「这个 IP 在哪儿」,靠的是外部 IP 库。 + // + // auto(缺省):ip-api.com 批量打底(全球覆盖、无需 key、一次 100 个), + // 配了 ip_geo_tencent_key 时再用腾讯位置服务精修国内 IP + // (省市 + GCJ-02 坐标)。腾讯的 IP 定位是一 IP 一请求, + // 拿它做批量主干会把 N 个 IP 变成 N 次 HTTP,故只做精修。 + // ipapi:只用 ip-api.com + // tencent:只用腾讯位置服务(要求配 key,否则降级为 ipapi) + // none:不查询,报告里只给 IP 不提地理信息(攻击者 IP 一个字节都不外发) + // + // 隐私边界:auto/ipapi 会把攻击者 IP 发给 ip-api.com,tencent 会发给腾讯。 + // 本报告只处理「攻击者」地址,不涉及任何用户 IP;不接受这一点请用 none。 + IPGeoProvider string `ini:"ip_geo_provider"` + // IPGeoTencentKey 腾讯位置服务 key(lbs.qq.com 申请)。留空则跳过腾讯精修。 + // 仅用于服务端 HTTP 请求,不下发到前端 —— 与地图 JS 用的 key 不是一回事。 + IPGeoTencentKey string `ini:"ip_geo_tencent_key"` + + // HoneypotReportEnabled 是否启用周期性安全状况汇总(周报 / 月报)邮件。 + // 缺省 true:报告本身是运维最需要的「有没有人在打、从哪儿打」, + // 而收件人缺失时会 WARN 跳过,不会静默。 + HoneypotReportEnabled bool `ini:"honeypot_report_enabled"` + // HoneypotReportWeekly / Monthly 分别控制周报与月报,缺省都开。 + HoneypotReportWeekly bool `ini:"honeypot_report_weekly"` + HoneypotReportMonthly bool `ini:"honeypot_report_monthly"` + // HoneypotReportEmail 报告收件人。留空回退 security_alert_email。 + // 单独开一个键是为了能把「随时可能发生的封禁告警」与「定期汇总」 + // 分投到不同邮箱(例如告警进手机推送邮箱、汇总进归档邮箱)。 + HoneypotReportEmail string `ini:"honeypot_report_email"` + // HoneypotReportDir 报告文件(HTML + PNG)的存放目录,缺省 data/reports。 + // 报告里有攻击者 IP 明细,目录权限按 0700 创建。 + HoneypotReportDir string `ini:"honeypot_report_dir"` } Server struct { diff --git a/internal/db/db.go b/internal/db/db.go index 5106a99..239dbf0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -45,6 +45,10 @@ var AllTables = []interface{}{ &UserRestriction{}, &ContentReport{}, // 蜜罐命中记录(审计证据,只追加不软删) &HoneypotHit{}, + // 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存) + &IPGeoRecord{}, + // 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信) + &SecurityReportLog{}, // 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证) &BanRecord{}, // 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释) diff --git a/internal/db/honeypot.go b/internal/db/honeypot.go index a41382b..35f5d03 100644 --- a/internal/db/honeypot.go +++ b/internal/db/honeypot.go @@ -97,6 +97,20 @@ type HoneypotStore interface { // 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引), // 因为磁盘压力来自索引和 TOAST,不只是堆表。 Size(ctx context.Context) (HoneypotSize, error) + // AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。 + // + // 安全汇总报告专用:报告要先按 IP 聚合再拿去地理定位,而不是把几十万条 + // 命中明细全捞进内存再 group —— 明细只在「命中数」这一个维度上有用, + // 聚合能把它压到「独立 IP 数」的量级。 + AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) + // AggregatePaths 统计 [since, until) 内被探测最多的路径。 + AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error) + // TimestampsBetween 返回 [since, until) 内所有命中的时间戳(仅这一列)。 + // + // 用于画每日趋势。刻意不在 SQL 里按天分组:DATE() 的返回类型在 + // PostgreSQL(date)与 MySQL([]byte)之间不一致,跨方言解析很脆; + // 而这一列的体积极小(时间戳 8 字节),取回来在 Go 里按天归并更稳。 + TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) // LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。 LegacyCredentialColumns(ctx context.Context) ([]string, error) // PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。 @@ -121,6 +135,20 @@ type HoneypotSourceStat struct { Provider string } +// HoneypotSourceAgg 是带「处置结果」的来源聚合行(安全汇总报告用)。 +// +// 与 HoneypotSourceStat 的区别:那个是给 CLI 排行看的(只要次数), +// 这个要给报告回答「打了多少次 / 有多少次被拦下 / 有多少次真的在试密码」。 +type HoneypotSourceAgg struct { + IP string + Hits int64 + Banned int64 + // Creds 带有凭据字段的命中数(真实破解尝试,区别于纯路径探测)。 + Creds int64 + // Provider 是该 IP 最近一次命中时的 cdncheck 标签(云厂商/CDN/WAF)。 + Provider string +} + // HoneypotPathStat 路径聚合行。 type HoneypotPathStat struct { Path string @@ -234,6 +262,55 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([ return rows, err } +// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。 +// +// [since, until) 左闭右开:周期报告的相邻两期不能有重叠,也不能漏掉 +// 恰好落在整点上的记录 —— 用闭区间会让周一的 00:00:00 同时算进上周和本周。 +func (h *honeypots) AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) { + var rows []*HoneypotSourceAgg + err := h.db.WithContext(ctx). + Table("honeypot_hits"). + // SUM(CASE WHEN ...) 是 PG 与 MySQL 都支持的写法(boolean 在 PG 里 + // 不能直接 sum,转 1/0 才跨方言)。 + Select("ip AS ip, count(*) AS hits, "+ + "sum(case when banned then 1 else 0 end) AS banned, "+ + "sum(case when had_fields then 1 else 0 end) AS creds, "+ + "max(provider) AS provider"). + Where("created_at >= ? AND created_at < ?", since, until). + Group("ip"). + Order("hits DESC"). + Scan(&rows).Error + return rows, err +} + +// AggregatePaths 统计 [since, until) 内被探测最多的路径。 +func (h *honeypots) AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error) { + if limit <= 0 || limit > 500 { + limit = 20 + } + var rows []*HoneypotPathStat + err := h.db.WithContext(ctx). + Table("honeypot_hits"). + Select("path AS path, count(*) AS count"). + Where("created_at >= ? AND created_at < ?", since, until). + Group("path"). + Order("count DESC"). + Limit(limit). + Scan(&rows).Error + return rows, err +} + +// TimestampsBetween 返回 [since, until) 内所有命中的时间戳。 +func (h *honeypots) TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) { + var ts []time.Time + err := h.db.WithContext(ctx). + Model(&HoneypotHit{}). + Where("created_at >= ? AND created_at < ?", since, until). + Order("created_at ASC"). + Pluck("created_at", &ts).Error + return ts, err +} + func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) { res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{}) return res.RowsAffected, res.Error diff --git a/internal/db/honeypot_test.go b/internal/db/honeypot_test.go index d8bc4a6..bbb4a6e 100644 --- a/internal/db/honeypot_test.go +++ b/internal/db/honeypot_test.go @@ -159,3 +159,73 @@ func TestHoneypotSize(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(2), info2.Rows) } + +// TestHoneypotAggregateRange 覆盖安全汇总报告用的区间聚合。 +// +// 三个方法共同回答「这一周谁在打、打了哪儿」。重点验证: +// - [since, until) 左闭右开,相邻两期不能重叠; +// - SUM(CASE WHEN ...) 在 PostgreSQL / MySQL 上都能算出处置数; +// - 只取时间戳那一列时不夹带整行(报告不需要 UA / 路径以外的字段)。 +func TestHoneypotAggregateRange(t *testing.T) { + database, cleanup := newTestDB(t) + ctx := context.Background() + + t.Cleanup(func() { + if err := cleanup("honeypot_hits"); err != nil { + t.Fatal(err) + } + }) + + store := NewHoneypotStore(database) + loc := time.Local + start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) + end := start.AddDate(0, 0, 7) + + rows := []*HoneypotHit{ + // 窗口内:IP A 三次(两次被拦、一次带凭据)、IP B 一次 + {IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(time.Hour)}, + {IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: true, Provider: "Azure", CreatedAt: start.Add(2 * time.Hour)}, + {IP: "203.0.113.1", Path: "/.env", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(3 * time.Hour)}, + {IP: "198.51.100.2", Path: "/wp-admin", Banned: false, HadFields: false, CreatedAt: start.Add(4 * time.Hour)}, + // 窗口外(右侧开区间边界):不能算进本期 + {IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, CreatedAt: end}, + // 窗口外(早于起点) + {IP: "192.0.2.9", Path: "/phpmyadmin", Banned: true, CreatedAt: start.Add(-time.Minute)}, + } + for _, r := range rows { + require.NoError(t, store.Create(ctx, r)) + } + + sources, err := store.AggregateSources(ctx, start, end) + require.NoError(t, err) + require.Len(t, sources, 2) + + // 按命中数降序:203.0.113.1 在前 + require.Equal(t, "203.0.113.1", sources[0].IP) + require.Equal(t, int64(3), sources[0].Hits) + require.Equal(t, int64(3), sources[0].Banned) + require.Equal(t, int64(1), sources[0].Creds) + require.Equal(t, "Azure", sources[0].Provider) + + require.Equal(t, "198.51.100.2", sources[1].IP) + require.Equal(t, int64(1), sources[1].Hits) + require.Equal(t, int64(0), sources[1].Banned) + + paths, err := store.AggregatePaths(ctx, start, end, 10) + require.NoError(t, err) + require.Len(t, paths, 3) + require.Equal(t, "/wp-login.php", paths[0].Path) + require.Equal(t, int64(2), paths[0].Count) + + stamps, err := store.TimestampsBetween(ctx, start, end) + require.NoError(t, err) + require.Len(t, stamps, 4, "右边界那条不能算进来") + for _, ts := range stamps { + require.True(t, ts.Before(end), "所有时间戳都应严格早于 end") + } + + // 空窗口必须返回空而不是报错(报告要能生成「本周无人扫描」) + empty, err := store.AggregateSources(ctx, start.AddDate(0, 0, 30), start.AddDate(0, 0, 37)) + require.NoError(t, err) + require.Empty(t, empty) +} diff --git a/internal/db/ipgeo.go b/internal/db/ipgeo.go new file mode 100644 index 0000000..486cc32 --- /dev/null +++ b/internal/db/ipgeo.go @@ -0,0 +1,151 @@ +// 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 db + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// IPGeoRecord 是一个来源 IP 的地理定位缓存。 +// +// 为什么必须落库而不是每次现查: +// - 免费 IP 库都有配额(ip-api 免费版 45 次/分钟)。一次周报可能有几百上千个 +// 不同来源 IP,现查必然撞限流,撞了就是「报告里一半 IP 没有位置」; +// - 同一个攻击者会在多份报告里反复出现,缓存能让第二次之后零外部请求; +// - 定位结果本身变化很慢(国家级几乎不变,城市级极少变),没必要重复付费。 +// +// 缓存键用 shared.LimitKey(IPv4 精确、IPv6 折叠 /64)而不是原始 IP: +// 攻击者常在一个 /64 里轮换地址,按精确地址缓存会把同一个 C 段查上几十次, +// 既浪费配额又得到一模一样的结果。 +type IPGeoRecord struct { + // IPKey 是缓存键(shared.LimitKey 的产物)。主键,不用自增 id: + // 这张表是纯 KV 缓存,没有任何按 id 遍历或引用的需求。 + IPKey string `gorm:"size:64;primarykey"` + + Country string `gorm:"size:64"` + CountryCode string `gorm:"size:8"` + Region string `gorm:"size:128"` + City string `gorm:"size:128"` + // ISP 运营商 / 网络名("China Telecom"、"Amazon.com, Inc.")。肉鸡报告里 + // 这个字段比国家更有信息量:能看出是家宽还是云主机。 + ISP string `gorm:"size:160"` + // ASN 自治域号("AS4134")。同 ASN 大量命中通常意味着同一片被控网络。 + ASN string `gorm:"size:64"` + + // Lat / Lon 为定位坐标。坐标系随来源不同: + // tencent → GCJ-02(火星坐标,与腾讯地图对齐) + // ipapi → WGS-84 + // 两者相差几百米,在世界地图气泡图尺度上无影响;放大到街道级才有意义。 + Lat float64 + Lon float64 + // HasCoords 标记坐标是否可用。IP 库对国家级、部分省级结果不给坐标, + // 用 0,0 表示「未知」会被画到几内亚湾去 —— 必须显式区分。 + HasCoords bool + + // Source 记录这条数据来自哪个库(tencent / ipapi),便于事后判断精度。 + Source string `gorm:"size:16"` + // Failed 标记查询失败。失败也要缓存:某个 IP 查不出来通常是确定性的 + // (保留地址、库未收录),反复重试只会白白消耗配额。 + Failed bool + + ResolvedAt time.Time `gorm:"index"` +} + +// TableName 固定表名。 +func (IPGeoRecord) TableName() string { return "ip_geo_cache" } + +// IPGeos 地理定位缓存全局 store。 +var IPGeos IPGeoStore + +// IPGeoStore 地理定位缓存的读写。 +type IPGeoStore interface { + // GetMany 批量取缓存,返回 map[IPKey]*IPGeoRecord。未命中的键不出现在结果里。 + GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error) + // UpsertMany 批量写入或覆盖。 + UpsertMany(ctx context.Context, records []*IPGeoRecord) error + // PurgeResolvedBefore 删除 resolved_at 早于 before 的记录(缓存过期)。 + // + // 注意「过期」与「失败」的 TTL 不同,由调用方分两次调: + // 成功结果留得久(90 天),失败结果留得短(3 天)—— 失败可能是临时故障 + // (对方 API 超时),留太久等于永久放弃这个 IP。 + PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error) + // Count 返回缓存条目数。 + Count(ctx context.Context) (int64, error) +} + +type ipGeos struct { + db *gorm.DB +} + +// NewIPGeoStore 构造缓存 store。务必传 db.DB 而不是复用什么包级单例。 +func NewIPGeoStore(db *gorm.DB) IPGeoStore { + return &ipGeos{db: db} +} + +func (s *ipGeos) GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error) { + out := make(map[string]*IPGeoRecord, len(keys)) + if len(keys) == 0 { + return out, nil + } + + // 分批 IN 查询:PostgreSQL 的占位符上限是 65535,MySQL 受 max_allowed_packet + // 约束,一次塞上千个键虽然多数情况能过,但没必要赌。 + const batch = 500 + for start := 0; start < len(keys); start += batch { + end := start + batch + if end > len(keys) { + end = len(keys) + } + var rows []*IPGeoRecord + if err := s.db.WithContext(ctx). + Where("ip_key IN ?", keys[start:end]). + Find(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + out[r.IPKey] = r + } + } + return out, nil +} + +func (s *ipGeos) UpsertMany(ctx context.Context, records []*IPGeoRecord) error { + if len(records) == 0 { + return nil + } + // 只更新定位字段,不更新 ip_key 本身(它是冲突判定列)。 + cols := []string{ + "country", "country_code", "region", "city", "isp", "asn", + "lat", "lon", "has_coords", "source", "failed", "resolved_at", + } + return s.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "ip_key"}}, + DoUpdates: clause.AssignmentColumns(cols), + }). + CreateInBatches(records, 100).Error +} + +func (s *ipGeos) PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error) { + q := s.db.WithContext(ctx).Where("resolved_at < ?", before) + if onlyFailed { + q = q.Where("failed = ?", true) + } else { + // 成功记录那一轮不要连失败的一起删:两者的 TTL 不同。 + q = q.Where("failed = ?", false) + } + res := q.Delete(&IPGeoRecord{}) + return res.RowsAffected, res.Error +} + +func (s *ipGeos) Count(ctx context.Context) (int64, error) { + var n int64 + err := s.db.WithContext(ctx).Model(&IPGeoRecord{}).Count(&n).Error + return n, err +} diff --git a/internal/db/ipgeo_test.go b/internal/db/ipgeo_test.go new file mode 100644 index 0000000..7ee3156 --- /dev/null +++ b/internal/db/ipgeo_test.go @@ -0,0 +1,174 @@ +// 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 db + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestIPGeoStore 覆盖地理定位缓存的读写、批量取、覆盖与过期清理。 +// +// 这张表是配额的保护层:同一批攻击者会在多份报告里反复出现, +// 「查一次、之后全靠缓存」能不能成立全看这里的语义对不对。 +func TestIPGeoStore(t *testing.T) { + database, cleanup := newTestDB(t) + ctx := context.Background() + + t.Cleanup(func() { + if err := cleanup("ip_geo_cache"); err != nil { + t.Fatal(err) + } + }) + + store := NewIPGeoStore(database) + now := time.Now() + + records := []*IPGeoRecord{ + { + IPKey: "203.0.113.1", Country: "美国", CountryCode: "US", + Region: "Virginia", City: "Ashburn", ISP: "Amazon", ASN: "AS14618", + Lat: 39.04, Lon: -77.49, HasCoords: true, Source: "ipapi", ResolvedAt: now, + }, + { + IPKey: "2001:db8::/64", Country: "中国", CountryCode: "CN", + Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, + HasCoords: true, Source: "tencent", ResolvedAt: now, + }, + { + // 查不到:Failed 标记要能落库,否则每次报告都会重试同一批地址 + IPKey: "198.51.100.9", Failed: true, ResolvedAt: now, + }, + } + require.NoError(t, store.UpsertMany(ctx, records)) + + got, err := store.GetMany(ctx, []string{"203.0.113.1", "2001:db8::/64", "198.51.100.9", "10.0.0.1"}) + require.NoError(t, err) + require.Len(t, got, 3, "不存在的键不应出现在结果里") + require.Equal(t, "Ashburn", got["203.0.113.1"].City) + require.Equal(t, "AS14618", got["203.0.113.1"].ASN) + require.True(t, got["203.0.113.1"].HasCoords) + require.Equal(t, "tencent", got["2001:db8::/64"].Source) + require.True(t, got["198.51.100.9"].Failed) + + // 覆盖写:同一个键再写一次应当更新而不是报主键冲突 + require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{{ + IPKey: "203.0.113.1", Country: "美国", CountryCode: "US", + City: "Reston", Lat: 38.96, Lon: -77.35, HasCoords: true, + Source: "tencent", ResolvedAt: now.Add(time.Hour), + }})) + got2, err := store.GetMany(ctx, []string{"203.0.113.1"}) + require.NoError(t, err) + require.Equal(t, "Reston", got2["203.0.113.1"].City) + require.Equal(t, "tencent", got2["203.0.113.1"].Source) + + n, err := store.Count(ctx) + require.NoError(t, err) + require.Equal(t, int64(3), n) +} + +// TestIPGeoStorePurge 覆盖两套 TTL 分开清理:成功留 90 天、失败留 3 天。 +func TestIPGeoStorePurge(t *testing.T) { + database, cleanup := newTestDB(t) + ctx := context.Background() + + t.Cleanup(func() { + if err := cleanup("ip_geo_cache"); err != nil { + t.Fatal(err) + } + }) + + store := NewIPGeoStore(database) + now := time.Now() + + require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{ + {IPKey: "1.0.0.1", Country: "澳大利亚", ResolvedAt: now.AddDate(0, 0, -100)}, // 成功且过期 + {IPKey: "1.0.0.2", Country: "日本", ResolvedAt: now.AddDate(0, 0, -10)}, // 成功且新鲜 + {IPKey: "1.0.0.3", Failed: true, ResolvedAt: now.AddDate(0, 0, -4)}, // 失败且过期 + {IPKey: "1.0.0.4", Failed: true, ResolvedAt: now}, // 失败且新鲜 + })) + + // 清失败的旧记录:只删 1.0.0.3 + n, err := store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -3), true) + require.NoError(t, err) + require.Equal(t, int64(1), n) + + // 清成功的旧记录:只删 1.0.0.1(不能连失败的一起删,两者 TTL 不同) + n, err = store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -90), false) + require.NoError(t, err) + require.Equal(t, int64(1), n) + + left, err := store.GetMany(ctx, []string{"1.0.0.1", "1.0.0.2", "1.0.0.3", "1.0.0.4"}) + require.NoError(t, err) + require.Len(t, left, 2) + require.Contains(t, left, "1.0.0.2") + require.Contains(t, left, "1.0.0.4") +} + +// TestSecurityReportStore 覆盖报告台账的幂等语义。 +// +// 这是「重启不会重复发同一期周报」的唯一保证:period_key 唯一索引 + upsert。 +func TestSecurityReportStore(t *testing.T) { + database, cleanup := newTestDB(t) + ctx := context.Background() + + t.Cleanup(func() { + if err := cleanup("security_report_logs"); err != nil { + t.Fatal(err) + } + }) + + store := NewSecurityReportStore(database) + start := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local) + end := start.AddDate(0, 0, 7) + + // 首次:不存在 + rec, err := store.Get(ctx, "week:2026-W43") + require.NoError(t, err) + require.Nil(t, rec) + + require.NoError(t, store.Save(ctx, &SecurityReportLog{ + PeriodKey: "week:2026-W43", Kind: "week", + WindowStart: start, WindowEnd: end, + Hits: 42, UniqueSources: 7, Countries: 3, + Recipient: "ops@example.com", Attempts: 1, Error: "smtp timeout", + })) + + rec, err = store.Get(ctx, "week:2026-W43") + require.NoError(t, err) + require.NotNil(t, rec) + require.False(t, rec.Sent) + require.Equal(t, 1, rec.Attempts) + + // 第二次:同一 period_key 再写应当覆盖,而不是插入第二行 —— + // 否则「这一期发过没」会被多行记录搞乱。 + require.NoError(t, store.Save(ctx, &SecurityReportLog{ + PeriodKey: "week:2026-W43", Kind: "week", + WindowStart: start, WindowEnd: end, + Hits: 42, UniqueSources: 7, Countries: 3, + Recipient: "ops@example.com", Attempts: 2, Sent: true, + })) + + rec, err = store.Get(ctx, "week:2026-W43") + require.NoError(t, err) + require.True(t, rec.Sent) + require.Equal(t, 2, rec.Attempts) + + recent, err := store.Recent(ctx, 10) + require.NoError(t, err) + require.Len(t, recent, 1, "同一期只能有一行台账") + + // 不同周期应各自一行 + require.NoError(t, store.Save(ctx, &SecurityReportLog{ + PeriodKey: "month:2026-10", Kind: "month", + WindowStart: start, WindowEnd: end, Sent: true, + })) + recent, err = store.Recent(ctx, 10) + require.NoError(t, err) + require.Len(t, recent, 2) +} diff --git a/internal/db/security_report.go b/internal/db/security_report.go new file mode 100644 index 0000000..de4c075 --- /dev/null +++ b/internal/db/security_report.go @@ -0,0 +1,118 @@ +// 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 db + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// SecurityReportLog 是「周期安全汇总已发送」的台账。 +// +// 为什么需要一张表而不是在内存里记一个时间戳: +// 服务会被重启(发版、OOM、机器迁移)。内存标记一重启就丢,而报告窗口是一整个 +// 自然周/月 —— 周一凌晨发过以后重启一次,下一次 tick 就会把同一周的周报再发一遍, +// 连着发三次的情况在真实运维里很常见(发布窗口经常撞在周一)。 +// +// 用 period_key 唯一索引做幂等:无论触发多少次、重启多少回,同一周期只发一次。 +type SecurityReportLog struct { + ID uint `gorm:"primarykey"` + + // PeriodKey 周期标识,形如 "week:2026-W44" / "month:2026-10"。 + // 唯一索引是幂等的唯一保证 —— 不要改成「按窗口时间范围查重」, + // 那样跨月边界(一个月 4 周 vs 5 周)会出现漏发或重发的灰色地带。 + PeriodKey string `gorm:"size:32;uniqueIndex"` + + // Kind 报告类型(week / month),便于按类型统计。 + Kind string `gorm:"size:8"` + // WindowStart / WindowEnd 报告覆盖的时间窗口 [start, end)。 + WindowStart time.Time + WindowEnd time.Time + + // 发送时的关键数字快照。台账要能独立回答「那周的结论是什么」, + // 而不是事后还得回去翻蜜罐表 —— 蜜罐表有保留期,过期就被清掉了。 + Hits int64 + UniqueSources int64 + Banned int64 + Countries int + + Recipient string `gorm:"size:255"` + // FilePath 报告 HTML 的落盘路径(留空表示只发了邮件、没留文件)。 + FilePath string `gorm:"size:512"` + + // Sent 是否成功发出。失败的行保留下来,既是审计线索, + // 也用来做「最多重试几次」的计数。 + Sent bool + // Attempts 尝试次数。发送失败会重试,但必须设上限: + // 一个配错的 SMTP 会让每次 tick 都失败,无限重试只是把日志刷满。 + Attempts int + // Error 最近一次失败的原因(成功时清空)。 + Error string `gorm:"size:512"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName 固定表名。 +func (SecurityReportLog) TableName() string { return "security_report_logs" } + +// SecurityReports 报告台账全局 store。 +var SecurityReports SecurityReportStore + +// SecurityReportStore 报告台账的读写。 +type SecurityReportStore interface { + // Get 按 period_key 取一条,未找到返回 (nil, nil)。 + Get(ctx context.Context, periodKey string) (*SecurityReportLog, error) + // Save 按 period_key upsert。 + Save(ctx context.Context, rec *SecurityReportLog) error + // Recent 返回最近 n 条,按窗口结束时间倒序。 + Recent(ctx context.Context, n int) ([]*SecurityReportLog, error) +} + +type securityReports struct { + db *gorm.DB +} + +// NewSecurityReportStore 构造台账 store。 +func NewSecurityReportStore(db *gorm.DB) SecurityReportStore { + return &securityReports{db: db} +} + +func (s *securityReports) Get(ctx context.Context, periodKey string) (*SecurityReportLog, error) { + var rec SecurityReportLog + err := s.db.WithContext(ctx).Where("period_key = ?", periodKey).First(&rec).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &rec, nil +} + +func (s *securityReports) Save(ctx context.Context, rec *SecurityReportLog) error { + return s.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "period_key"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "kind", "window_start", "window_end", "hits", "unique_sources", + "banned", "countries", "recipient", "file_path", + "sent", "attempts", "error", "updated_at", + }), + }). + Create(rec).Error +} + +func (s *securityReports) Recent(ctx context.Context, n int) ([]*SecurityReportLog, error) { + if n <= 0 { + n = 12 + } + var rows []*SecurityReportLog + err := s.db.WithContext(ctx).Order("window_end DESC").Limit(n).Find(&rows).Error + return rows, err +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 251214e..2ca38f7 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -10,6 +10,7 @@ import ( "embed" "fmt" "html/template" + "path/filepath" "strings" "time" @@ -247,12 +248,54 @@ func sendTemplateMail(email, title string, templateFS embed.FS, templatePath str } func sendMail(to, title, content string) error { + return sendMailWith(to, title, content, nil, nil) +} + +// MailAttachment 邮件附件。 +type MailAttachment struct { + Path string + // Name 附件显示名,留空则用文件名。 + Name string +} + +// MailInlineImage 正文内嵌图片。 +// +// CID 是 Content-ID:正文 HTML 里写 ,邮件客户端就会用 +// 附件里的这张图渲染。这是让「地图不依赖任何外链、不依赖 JS」的唯一可靠办法 —— +// 直接放外链图片,Gmail 默认不加载远程图片;放内联 SVG,Gmail/Outlook 会剥掉。 +type MailInlineImage struct { + Path string + CID string +} + +// sendMailWith 发送一封带内嵌图片与附件的 HTML 邮件。 +// +// 图片与附件都从本地路径读取(调用方负责把文件写到临时目录或报告目录)。 +// 任何一步失败都向上返回错误:报告这种「发了就该完整」的邮件, +// 静默丢附件比直接报错更糟。 +func sendMailWith(to, title, content string, inline []MailInlineImage, atts []MailAttachment) error { m := gomail.NewMessage() m.SetHeader("From", fmt.Sprintf("TamaBox <%s>", conf.Mail.Account)) m.SetHeader("To", to) m.SetHeader("Subject", title) m.SetBody("text/html", content) + for _, img := range inline { + // 显式指定 Content-ID:gomail 的 Embed 默认会给一个随机值, + // 那样正文里就没法引用它。 + m.Embed(img.Path, gomail.SetHeader(map[string][]string{ + "Content-ID": {"<" + img.CID + ">"}, + "Content-Disposition": {`inline; filename="` + filepath.Base(img.Path) + `"`}, + })) + } + for _, a := range atts { + opts := []gomail.FileSetting{} + if a.Name != "" { + opts = append(opts, gomail.Rename(a.Name)) + } + m.Attach(a.Path, opts...) + } + d := gomail.NewDialer( conf.Mail.SMTP, conf.Mail.Port, @@ -265,3 +308,72 @@ func sendMail(to, title, content string) error { } return d.DialAndSend(m) } + +// SecurityReportMail 周期安全汇总邮件的载荷。 +// +// HTML 片段用 template.HTML 传:报告正文里有 KPI 卡片和表格, +// 走 string 会被 html/template 转义成源码文本,收件人看到的是一堆尖括号。 +type SecurityReportMail struct { + // To 收件人,由调用方决定(honeypot_report_email / security_alert_email)。 + To string + Title string + Period string + Generated string + // MapCID 正文内嵌地图的 Content-ID;为空则不发地图(报告无数据时)。 + MapCID string + // SummaryHTML KPI 卡片片段。 + SummaryHTML template.HTML + // CountriesHTML 国家排行表片段。 + CountriesHTML template.HTML + // Notes 口径说明。用 template.HTML 传:里面有多行
结构。 + Notes template.HTML + // Attachments 附件(报告 HTML)。 + Attachments []MailAttachment + // InlineImages 内嵌图片(地图 PNG),其 CID 必须与 MapCID 一致。 + InlineImages []MailInlineImage +} + +// securityReportData 是模板的渲染载荷(独立成一个类型,方便单独测渲染)。 +type securityReportData struct { + Title string + Period string + Generated string + MapCID string + SummaryHTML template.HTML + CountriesHTML template.HTML + Notes template.HTML +} + +// SendSecurityReportMail 发送周期安全汇总邮件。 +func SendSecurityReportMail(r SecurityReportMail) error { + content, err := RenderSecurityReportMail(r) + if err != nil { + return err + } + return sendMailWith(r.To, r.Title, content, r.InlineImages, r.Attachments) +} + +// RenderSecurityReportMail 只渲染邮件正文,不发信。 +// +// 单独拆出来是为了能在没有 SMTP 的环境里验证模板:模板里的字段名写错一个字母, +// 生产上表现为「报告发出去了但正文是空的」,而这封邮件一周才发一次, +// 等发现时已经缺了好几期。 +func RenderSecurityReportMail(r SecurityReportMail) (string, error) { + t, err := template.ParseFS(templates.FS, "mail/security-report.html") + if err != nil { + return "", errors.Wrap(err, "parse template file") + } + var content bytes.Buffer + if err := t.Execute(&content, securityReportData{ + Title: r.Title, + Period: r.Period, + Generated: r.Generated, + MapCID: r.MapCID, + SummaryHTML: r.SummaryHTML, + CountriesHTML: r.CountriesHTML, + Notes: r.Notes, + }); err != nil { + return "", errors.Wrap(err, "execute template") + } + return content.String(), nil +} diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go new file mode 100644 index 0000000..943e6dc --- /dev/null +++ b/internal/mail/mail_test.go @@ -0,0 +1,52 @@ +// 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 mail + +import ( + "html/template" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRenderSecurityReportMail 钉住报告邮件模板本身。 +// +// 这封邮件一周才发一次,模板字段名写错的表现是「发出去了但正文空/漏块」, +// 很难在第一时间发现。这里同时验证两件容易出错的事: +// 1. HTML 片段必须原样插入(走 template.HTML),不能被转义成源码文本; +// 2. 地图走 cid: 引用,邮件客户端才会把它当内嵌图而不是外链。 +func TestRenderSecurityReportMail(t *testing.T) { + out, err := RenderSecurityReportMail(SecurityReportMail{ + Title: "2026 年 10 月 第 43 周安全状况周报", + Period: "2026-10-19 ~ 2026-10-25", + Generated: "2026-10-30 16:16", + MapCID: "map-43", + SummaryHTML: template.HTML(`
命中 554 次
`), + CountriesHTML: template.HTML(`
美国160
`), + Notes: template.HTML(`口径一
口径二`), + }) + require.NoError(t, err) + + // HTML 片段原样出现 + require.Contains(t, out, "554") + require.Contains(t, out, "美国") + require.Contains(t, out, "口径一
口径二") + require.NotContains(t, out, "<strong>", "片段被转义了,收件人只会看到一堆尖括号") + + // 地图按 Content-ID 引用 + require.Contains(t, out, "cid:map-43") + + // 模板里的变量都应已展开 + require.NotContains(t, out, "{{") + require.Contains(t, out, "2026-10-19 ~ 2026-10-25") +} + +// TestRenderSecurityReportMailNoMap 没有命中时不发地图,也就不该留一个坏图位。 +func TestRenderSecurityReportMailNoMap(t *testing.T) { + out, err := RenderSecurityReportMail(SecurityReportMail{Title: "空报告"}) + require.NoError(t, err) + require.NotContains(t, out, "cid:", "无数据时不该引用不存在的地图") + require.NotContains(t, out, "{{") +} diff --git a/internal/route/honeypot_test.go b/internal/route/honeypot_test.go index 8907b7a..6c6d59f 100644 --- a/internal/route/honeypot_test.go +++ b/internal/route/honeypot_test.go @@ -513,6 +513,20 @@ func (c *captureStore) PurgeLegacyCredentialColumns(context.Context) ([]string, return nil, nil } +// 周期报告用到的时间窗口聚合。这组用例不测它们(报告有自己的单测), +// 但接口扩展后必须补上,否则整个包编译不过。 +func (c *captureStore) AggregateSources(context.Context, time.Time, time.Time) ([]*db.HoneypotSourceAgg, error) { + return nil, nil +} + +func (c *captureStore) AggregatePaths(context.Context, time.Time, time.Time, int) ([]*db.HoneypotPathStat, error) { + return nil, nil +} + +func (c *captureStore) TimestampsBetween(context.Context, time.Time, time.Time) ([]time.Time, error) { + return nil, nil +} + // TestHoneypotServesFamilySpecificPage 验证按路径分发到不同后台的伪装。 // // 扫描器先按路径判断「这是什么后台」,再拿对应字段名去填表; diff --git a/internal/security/geoip/geoip.go b/internal/security/geoip/geoip.go new file mode 100644 index 0000000..dd5665c --- /dev/null +++ b/internal/security/geoip/geoip.go @@ -0,0 +1,636 @@ +// 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 geoip 把 IP 解析成地理位置(国家 / 省 / 市 / 运营商 / 坐标)。 +// +// 与 internal/security/ipinfo 的分工:ipinfo 用离线指纹判断「这个 IP 是不是 +// 云厂商 / CDN / WAF 的出口」,对家宽、企业宽带、移动网络一律返回 +// 「未识别(普通网络)」—— 它回答的是「是不是代理」,不是「在哪儿」。 +// 肉鸡恰恰全是后一类地址,所以安全汇总必须另有一套真正的 IP 地理位置库。 +// +// 数据源策略(多源回退): +// - ip-api.com:批量接口一次 100 个 IP,全球覆盖好,免费版无需 key。 +// 用它做批量主干 —— 免费版限 45 次/分钟,但一次请求覆盖 100 个 IP, +// 实际吞吐足够,且把「N 个 IP」压成「N/100 次请求」。 +// - 腾讯位置服务:一 IP 一请求,国内 IP 的省市准确度更高、坐标是 GCJ-02 +// (与腾讯地图底图对齐)。只用来**精修**国内的 IP,不做批量主干 —— +// 拿它打批量会把几百个 IP 变成几百次 HTTP,既慢又容易撞配额。 +// +// 隐私边界:会把攻击者 IP 发给上述第三方。这是刻意取舍(不查就画不出地图), +// 但调用方必须清楚 —— 本包只应处理攻击者地址,绝不要拿它去查真实用户 IP。 +package geoip + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +// Provider 常量。 +const ( + // ProviderAuto 缺省:ip-api 批量打底 + 腾讯精修国内。 + ProviderAuto = "auto" + // ProviderIPAPI 只用 ip-api.com。 + ProviderIPAPI = "ipapi" + // ProviderTencent 只用腾讯位置服务(无 key 时自动降级为 ipapi)。 + ProviderTencent = "tencent" + // ProviderNone 不查询任何外部服务。 + ProviderNone = "none" +) + +const ( + // ipAPIBatchSize ip-api 批量接口的硬上限。 + ipAPIBatchSize = 100 + // ipAPIMinInterval ip-api 免费版限流 45 次/分钟。取 1.4s 间隔(≈43 次/分钟) + // 留一点余量:多个报告同时跑、或用户手动跑 CLI 时不会互相挤爆。 + ipAPIMinInterval = 1400 * time.Millisecond + // defaultTencentRefineCap 腾讯精修最多处理多少个 IP。 + // + // 必须有上限:周报可能有两千个不同来源 IP,其中一半是国内 —— 逐个请求 + // 腾讯会跑十几分钟并吃掉当日免费配额,而报告的价值并不取决于把最后一千个 + // 长尾 IP 精确到市。取命中次数最高的前 N 个,够回答「主要从哪儿来」。 + defaultTencentRefineCap = 200 + // defaultTimeout 单次外部请求超时。 + // + // ⚠️ 不能设小。实测 ip-api 免费版的批量接口「22 个 IP」要 4–10 秒, + // 且同一批重复请求会更慢(免费额度在按 IP 做节流)。最早设 15 秒时 + // 第一轮真实数据就超时了 —— 免费服务不是快服务,超时要按最坏情况给。 + defaultTimeout = 45 * time.Second + // ipAPIAttempts 单个批次最多尝试几次。 + // 免费接口偶发慢响应/网关抖动很常见,重试一次成本可接受(有 1.4s 节流兜底), + // 但重试多了只会放大超时。 + ipAPIAttempts = 2 + // ipAPIRetryDelay 重试前的等待。 + ipAPIRetryDelay = 3 * time.Second +) + +// Geo 一个 IP 的地理定位结果。 +type Geo struct { + Country string + CountryCode string + Region string + City string + ISP string + ASN string + Lat float64 + Lon float64 + HasCoords bool + Source string +} + +// Options 解析器配置。 +type Options struct { + // Provider auto / ipapi / tencent / none,空视为 auto。 + Provider string + // TencentKey 腾讯位置服务 key,留空则跳过精修。 + TencentKey string + // TencentRefineCap 腾讯精修上限,<=0 用默认值。 + TencentRefineCap int + // HTTPClient 可注入(测试用),nil 用带超时的默认 client。 + HTTPClient *http.Client +} + +// Resolver 按配置解析 IP 地理位置。 +type Resolver struct { + provider string + tencent string + refineCap int + client *http.Client + // lastReq 串行化 ip-api 请求以遵守限流(多个报告并发时也不会超频)。 + mu sync.Mutex + lastReq time.Time +} + +// New 构造解析器。provider 值不认识时回退 auto 并告警 —— +// 静默回退会让人以为配置生效了,必须留痕。 +func New(opts Options) *Resolver { + p := strings.ToLower(strings.TrimSpace(opts.Provider)) + switch p { + case ProviderAuto, ProviderIPAPI, ProviderTencent, ProviderNone: + case "": + p = ProviderAuto + default: + logrus.WithField("value", opts.Provider). + Warn("ip_geo_provider 取值无法识别,已回退 auto(可选值:auto / ipapi / tencent / none)") + p = ProviderAuto + } + + // 只选了 tencent 却没配 key:无法工作,降级 ipapi 而不是返回空结果。 + if p == ProviderTencent && strings.TrimSpace(opts.TencentKey) == "" { + logrus.Warn("ip_geo_provider = tencent 但未配置 ip_geo_tencent_key,已降级为 ipapi") + p = ProviderIPAPI + } + + cap := opts.TencentRefineCap + if cap <= 0 { + cap = defaultTencentRefineCap + } + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: defaultTimeout} + } + return &Resolver{ + provider: p, + tencent: strings.TrimSpace(opts.TencentKey), + refineCap: cap, + client: client, + } +} + +// Enabled 报告解析器是否真的会去查外部服务。 +func (r *Resolver) Enabled() bool { return r != nil && r.provider != ProviderNone } + +// Describe 返回人类可读的数据源描述(写进报告,让读者知道坐标有多可信)。 +func (r *Resolver) Describe() string { + if r == nil { + return "未启用地理定位" + } + switch r.provider { + case ProviderNone: + return "未启用地理定位(ip_geo_provider = none)" + case ProviderIPAPI: + return "ip-api.com(批量,城市级)" + case ProviderTencent: + return "腾讯位置服务(城市级,GCJ-02)" + default: + if r.tencent != "" { + return "ip-api.com 批量 + 腾讯位置服务精修国内 IP" + } + return "ip-api.com(批量,城市级;未配置腾讯 key,国内 IP 未精修)" + } +} + +// Result 一次批量定位的结果。 +// +// ⚠️ 必须区分「查不到」与「没查成」:这是本包最容易埋错的地方。 +// - Geo[key] 存在 = 查到了; +// - Unresolved[key] = 服务**明确回答**没有这个 IP 的数据(可以短期缓存, +// 免得每份报告都去重试一个注定查不到的地址); +// - 两者都没有 = 请求本身失败了(超时 / 限流 / 网关抖动)。 +// 此时**绝不能**写缓存 —— 把网络故障记成「这个 IP 没有位置」, +// 会让接下来三天的报告都缺这一批 IP,而且没有任何线索说明为什么。 +type Result struct { + Geo map[string]Geo + Unresolved map[string]bool + // Requests / Failures 本次发出的请求数与失败的请求数(日志用)。 + Requests int + Failures int +} + +// Lookup 批量解析。keys 是 shared.LimitKey 的产物(IPv4 精确地址 / IPv6 的 +// "xxxx::/64")。 +// +// 任何失败都只记日志并返回已拿到的部分结果 —— 报告不该因为外部 API 抖动而失败。 +func (r *Resolver) Lookup(ctx context.Context, keys []string) Result { + res := Result{Geo: make(map[string]Geo, len(keys)), Unresolved: make(map[string]bool)} + if r == nil || r.provider == ProviderNone || len(keys) == 0 { + return res + } + + // 把缓存键还原成可查询的地址:IPv6 键带 "/64" 后缀,查询时用网络地址 + // (geo 库对前缀同样能回答,用基址是常见做法)。 + // + // ⚠️ 必须按 keys 的原始顺序建 addrs:调用方(报告)是按命中次数从高到低 + // 传进来的,腾讯精修只取前 refineCap 个。如果这里遍历 map 取地址, + // Go 的 map 迭代顺序是随机的 —— 每次运行精修到的 IP 都不一样, + // 配额会花在长尾地址上,而「主要从哪个城市来」恰恰取决于最活跃的那批。 + queryable := make(map[string]string, len(keys)) // 查询地址 -> 原始键 + addrs := make([]string, 0, len(keys)) + for _, key := range keys { + addr, ok := queryAddr(key) + if !ok { + continue + } + if _, dup := queryable[addr]; dup { + continue + } + queryable[addr] = key + addrs = append(addrs, addr) + } + if len(addrs) == 0 { + return res + } + + if r.provider == ProviderAuto || r.provider == ProviderIPAPI { + got, unresolved, reqs, fails := r.lookupIPAPI(ctx, addrs) + res.Requests += reqs + res.Failures += fails + for addr, g := range got { + if key, ok := queryable[addr]; ok { + res.Geo[key] = g + } + } + for addr := range unresolved { + if key, ok := queryable[addr]; ok { + res.Unresolved[key] = true + } + } + } + + // 腾讯精修:auto 只精修国内,tencent 覆盖全部。 + if r.tencent != "" && r.provider != ProviderIPAPI { + var refine []string + for _, addr := range addrs { + key := queryable[addr] + if r.provider == ProviderAuto { + g, ok := res.Geo[key] + // 只精修 ip-api 已判为中国的条目:海外 IP 腾讯给不出省市, + // 查了也是白白消耗配额。 + if !ok || !isChina(g.CountryCode) { + continue + } + } + refine = append(refine, addr) + } + if len(refine) > r.refineCap { + refine = refine[:r.refineCap] + } + tgot, tunresolved, treqs, tfails := r.lookupTencent(ctx, refine) + res.Requests += treqs + res.Failures += tfails + for addr, g := range tgot { + if key, ok := queryable[addr]; ok { + // 保留 ip-api 给的国家(腾讯对海外 IP 的 nation 常常是空的), + // 用腾讯的省市与坐标覆盖。 + prev := res.Geo[key] + if g.Country == "" { + g.Country, g.CountryCode = prev.Country, prev.CountryCode + } + res.Geo[key] = g + // 精修成功即视为已定位,撤掉可能存在的「查不到」标记。 + delete(res.Unresolved, key) + } + } + for addr := range tunresolved { + if key, ok := queryable[addr]; ok { + if _, done := res.Geo[key]; !done { + res.Unresolved[key] = true + } + } + } + } + return res +} + +// isChina 判断国家码是否为中国。 +// +// 只看 CN:港澳台在多数 IP 库里是 HK/MO/TW 单独编码,它们的城市名当前 +// 不会进入腾讯精修 —— 不做特判,宁可少精修也不要把地区归属搞错。 +func isChina(code string) bool { return strings.EqualFold(code, "CN") } + +// queryAddr 把缓存键还原为可查询的地址;不可查询(私网/回环/非法)返回 false。 +func queryAddr(key string) (string, bool) { + base := key + if i := strings.IndexByte(base, '/'); i >= 0 { + base = base[:i] + } + ip := net.ParseIP(base) + if ip == nil || !isPublic(ip) { + return "", false + } + return ip.String(), true +} + +// isPublic 排除私网 / 回环 / 链路本地 / 唯一本地地址。 +// 这些地址查不出位置,发出去也毫无意义(还会浪费配额)。 +func isPublic(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() { + return false + } + // IPv4 映射到 IPv6 的形式(::ffff:1.2.3.4)统一按 v4 处理。 + if v4 := ip.To4(); v4 != nil { + return !(v4[0] == 0 || v4[0] == 10 || v4[0] == 127 || + (v4[0] == 169 && v4[1] == 254) || + (v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) || + (v4[0] == 192 && v4[1] == 168) || + (v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127)) + } + return true +} + +// ---- ip-api.com ---- + +type ipAPIResult struct { + Query string `json:"query"` + Status string `json:"status"` + Message string `json:"message"` + Country string `json:"country"` + CountryCode string `json:"countryCode"` + RegionName string `json:"regionName"` + City string `json:"city"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + ISP string `json:"isp"` + AS string `json:"as"` +} + +// lookupIPAPI 分批查询 ip-api.com。 +// +// 返回三样东西: +// - got:查到的地址 → Geo; +// - unresolved:服务明确回答「这个地址没有数据」的地址; +// - requests / failures:发出的请求数与失败数。 +// +// 失败的批次整批都不进 unresolved —— 区别于「服务说没有这个 IP 的数据」。 +// +// 免费版只支持 http(https 要 pro)—— 这是该服务的既定限制,不是笔误。 +// 请求体里只有攻击者 IP,不含任何站点数据。 +func (r *Resolver) lookupIPAPI(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) { + got := make(map[string]Geo, len(addrs)) + unresolved := make(map[string]bool) + requests, failures := 0, 0 + + for start := 0; start < len(addrs); start += ipAPIBatchSize { + end := start + ipAPIBatchSize + if end > len(addrs) { + end = len(addrs) + } + batch := addrs[start:end] + + res, err := r.ipAPIBatchWithRetry(ctx, batch) + requests++ + if err != nil { + failures++ + logrus.WithError(err).WithField("count", len(batch)). + Warn("ip-api 批量查询失败:这批 IP 本次不定位,且不会写入缓存(下次运行会重试)") + continue + } + for addr, g := range res.geo { + got[addr] = g + } + for addr := range res.unresolved { + unresolved[addr] = true + } + } + return got, unresolved, requests, failures +} + +// batchOutcome 一个批次的解析结果。 +type batchOutcome struct { + geo map[string]Geo + unresolved map[string]bool +} + +// ipAPIBatchWithRetry 带一次重试的批量请求。 +// +// 为什么要重试:ip-api 免费版对同一来源有节流,偶发慢响应/网关 5xx 很常见。 +// 不重试的话,一次抖动就会让整份报告缺掉最多 100 个 IP(而报告一周只出一次, +// 没有「下个小时再看」的机会)。重试一次的成本是 3 秒等待,值得。 +func (r *Resolver) ipAPIBatchWithRetry(ctx context.Context, addrs []string) (*batchOutcome, error) { + var lastErr error + for attempt := 1; attempt <= ipAPIAttempts; attempt++ { + res, err := r.ipAPIBatch(ctx, addrs) + if err == nil { + return res, nil + } + lastErr = err + if attempt < ipAPIAttempts { + logrus.WithError(err).WithField("attempt", attempt).Debug("ip-api 批量查询失败,准备重试") + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(ipAPIRetryDelay): + } + } + } + return nil, lastErr +} + +func (r *Resolver) ipAPIBatch(ctx context.Context, addrs []string) (*batchOutcome, error) { + // 按序返回:ip-api 的批量接口保证结果顺序与输入一致,用下标对应最稳 + // (不要拿返回的 query 字段当键 —— 它对 IPv6 会做规范化,和输入不逐字相同)。 + body, err := json.Marshal(addrs) + if err != nil { + return nil, errors.Wrap(err, "marshal batch") + } + + if err := r.waitTurn(); err != nil { + return nil, err + } + + url := "http://ip-api.com/batch?fields=status,message,query,country,countryCode,regionName,city,lat,lon,isp,as" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrap(err, "build request") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := r.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "request") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return nil, errors.Errorf("ip-api status %d: %s", resp.StatusCode, string(snippet)) + } + + var results []ipAPIResult + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return nil, errors.Wrap(err, "decode") + } + + out := &batchOutcome{geo: make(map[string]Geo, len(results)), unresolved: make(map[string]bool)} + for i, item := range results { + if i >= len(addrs) { + break + } + if item.Status != "success" { + // 服务明确回答了这个地址没有数据(保留地址 / 未收录)。 + // 只有这种情况才允许进 unresolved(也就是允许被缓存成「查不到」)。 + out.unresolved[addrs[i]] = true + continue + } + out.geo[addrs[i]] = Geo{ + Country: item.Country, + CountryCode: item.CountryCode, + Region: item.RegionName, + City: item.City, + ISP: item.ISP, + ASN: normalizeASN(item.AS), + Lat: item.Lat, + Lon: item.Lon, + // ip-api 对国家级结果也会给一个中心点坐标(精度差但可用), + // 只要不是 (0,0) 就认为有坐标。 + HasCoords: item.Lat != 0 || item.Lon != 0, + Source: ProviderIPAPI, + } + } + return out, nil +} + +// waitTurn 串行地保证两次 ip-api 请求间隔不小于 ipAPIMinInterval。 +func (r *Resolver) waitTurn() error { + r.mu.Lock() + defer r.mu.Unlock() + if !r.lastReq.IsZero() { + if wait := ipAPIMinInterval - time.Since(r.lastReq); wait > 0 { + time.Sleep(wait) + } + } + r.lastReq = time.Now() + return nil +} + +// normalizeASN 把 ip-api 的 "AS15169 Google LLC" 拆成 "AS15169"。 +func normalizeASN(as string) string { + as = strings.TrimSpace(as) + if as == "" { + return "" + } + if i := strings.IndexByte(as, ' '); i > 0 { + return as[:i] + } + return as +} + +// ---- 腾讯位置服务 ---- + +type tencentResult struct { + Status int `json:"status"` + Message string `json:"message"` + Result struct { + IP string `json:"ip"` + Location struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + } `json:"location"` + AdInfo struct { + Nation string `json:"nation"` + Province string `json:"province"` + City string `json:"city"` + District string `json:"district"` + } `json:"ad_info"` + } `json:"result"` +} + +// tencentOutcome 单个地址的查询结果分类。 +type tencentOutcome int + +const ( + // tencentFound 查到并给出了行政区划。 + tencentFound tencentOutcome = iota + // tencentNoData 服务明确回答没有这个地址的数据(status != 0)。 + tencentNoData + // tencentFailed 网络 / 解析失败 —— 与「没有数据」不是一回事,不能缓存。 + tencentFailed +) + +// lookupTencent 逐个查询腾讯 IP 定位(并发度 2,别把免费配额打爆)。 +func (r *Resolver) lookupTencent(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) { + got := make(map[string]Geo, len(addrs)) + unresolved := make(map[string]bool) + if len(addrs) == 0 || r.tencent == "" { + return got, unresolved, 0, 0 + } + + const workers = 2 + var ( + mu sync.Mutex + wg sync.WaitGroup + sem = make(chan struct{}, workers) + failures int + ) + for _, addr := range addrs { + wg.Add(1) + go func(addr string) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + g, oc := r.tencentOne(ctx, addr) + mu.Lock() + defer mu.Unlock() + switch oc { + case tencentFound: + got[addr] = g + case tencentNoData: + unresolved[addr] = true + default: + failures++ + } + }(addr) + } + wg.Wait() + return got, unresolved, len(addrs), failures +} + +func (r *Resolver) tencentOne(ctx context.Context, addr string) (Geo, tencentOutcome) { + url := fmt.Sprintf("https://apis.map.qq.com/ws/location/v1/ip?ip=%s&key=%s", addr, r.tencent) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Geo{}, tencentFailed + } + resp, err := r.client.Do(req) + if err != nil { + return Geo{}, tencentFailed + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return Geo{}, tencentFailed + } + + var tr tencentResult + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return Geo{}, tencentFailed + } + // status != 0 表示查询失败(配额用尽、key 无效、IP 无数据等)。 + // 只记 Debug:几千个 IP 逐个刷警告会把日志淹掉。 + if tr.Status != 0 { + logrus.WithFields(logrus.Fields{"status": tr.Status, "message": tr.Message}). + Debug("腾讯 IP 定位返回非 0 状态") + // 配额用尽 / key 无效属于「配置或额度问题」,不是「这个 IP 没数据」。 + // 把它们混为一谈会把整批 IP 缓存成「查不到」,此后三天都不再精修。 + return Geo{}, tencentFailed + } + + ad := tr.Result.AdInfo + city := ad.City + if city == "" { + city = ad.District + } + if ad.Province == "" && city == "" { + // 状态成功但没有任何行政区划信息:这才是「这个地址没数据」。 + return Geo{}, tencentNoData + } + return Geo{ + Country: ad.Nation, + CountryCode: tencentCountryCode(ad.Nation), + Region: ad.Province, + City: city, + Lat: tr.Result.Location.Lat, + Lon: tr.Result.Location.Lng, + HasCoords: tr.Result.Location.Lat != 0 || tr.Result.Location.Lng != 0, + Source: ProviderTencent, + }, tencentFound +} + +// tencentCountryCode 从腾讯返回的 nation 推国家码。 +// +// 不能无脑写 "CN":provider = tencent 时(不先过 ip-api)所有地址都会走这条路, +// 海外 IP 的 nation 会是「美国」之类,硬编码 CN 会把它们统计成中国来源 —— +// 而「攻击来自哪些国家」正是这份报告的核心结论之一。 +// +// 只认「中国」这个精确值:港澳台在腾讯的 nation 里是「中国香港」等, +// 与 ip-api 的 HK/MO/TW 编码对不上,留空交给上层按国家名兜底更安全。 +func tencentCountryCode(nation string) string { + if strings.TrimSpace(nation) == "中国" { + return "CN" + } + return "" +} diff --git a/internal/security/geoip/geoip_test.go b/internal/security/geoip/geoip_test.go new file mode 100644 index 0000000..72069ea --- /dev/null +++ b/internal/security/geoip/geoip_test.go @@ -0,0 +1,500 @@ +// 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 geoip + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rewriteTransport 把真实域名的请求改投到本地测试服务器。 +// +// 用改投而不是给生产代码加一个 baseURL 字段:生产代码里不该为了测试多出 +// 「可以被指向别处」的开关,那类开关一旦被配置错就是把攻击者 IP 发到未知主机。 +type rewriteTransport struct { + target *url.URL +} + +func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + next := req.Clone(req.Context()) + next.URL.Scheme = t.target.Scheme + next.URL.Host = t.target.Host + return http.DefaultTransport.RoundTrip(next) +} + +// newTestClient 返回一个把所有出站请求改投到 srv 的 client。 +func newTestClient(t *testing.T, srv *httptest.Server) *http.Client { + t.Helper() + u, err := url.Parse(srv.URL) + require.NoError(t, err) + return &http.Client{Transport: &rewriteTransport{target: u}} +} + +// ipAPIServer 起一个假的 ip-api 批量接口。 +// +// respond 收到本批请求的地址列表,返回对应的 ipAPIResult 切片; +// status 非 0 时直接返回该 HTTP 状态码(用于模拟网关故障)。 +func ipAPIServer(t *testing.T, respond func(addrs []string) ([]ipAPIResult, int)) (*httptest.Server, *int32) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/batch" { + http.NotFound(w, req) + return + } + if req.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + atomic.AddInt32(&calls, 1) + var addrs []string + if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + results, code := respond(addrs) + if code != 0 { + http.Error(w, "upstream failure", code) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(results) + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +// okIP 构造一条成功的 ip-api 结果。 +func okIP(addr, country, code, city string, lat, lon float64, isp, as string) ipAPIResult { + return ipAPIResult{ + Query: addr, Status: "success", Country: country, CountryCode: code, + City: city, Lat: lat, Lon: lon, ISP: isp, AS: as, + } +} + +// TestLookupSuccess 三态里的「查到了」与「服务明确说没有」。 +func TestLookupSuccess(t *testing.T) { + srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) { + return []ipAPIResult{ + okIP(addrs[0], "德国", "DE", "Berlin", 52.52, 13.4, "Hetzner", "AS24940 Hetzner Online GmbH"), + {Query: addrs[1], Status: "fail", Message: "reserved range"}, + }, 0 + }) + + r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{"5.9.1.1", "203.0.113.7"}) + + require.Equal(t, int32(1), atomic.LoadInt32(calls)) + require.Equal(t, 1, res.Requests) + require.Equal(t, 0, res.Failures) + + g, ok := res.Geo["5.9.1.1"] + require.True(t, ok) + assert.Equal(t, "德国", g.Country) + assert.Equal(t, "DE", g.CountryCode) + assert.Equal(t, "Berlin", g.City) + assert.Equal(t, "AS24940", g.ASN, "AS 字段必须截成纯 ASN,否则国家统计里全是长串") + assert.True(t, g.HasCoords) + assert.Equal(t, ProviderIPAPI, g.Source) + + // status != success 才是「服务明确说没有」,才可以被缓存成查不到 + assert.True(t, res.Unresolved["203.0.113.7"]) + assert.NotContains(t, res.Geo, "203.0.113.7") +} + +// TestLookupNetworkFailure 三态里的「没查成」:绝不能进 Unresolved。 +// +// 这条断言是本包最重要的回归保护:一旦失败也进 Unresolved,上层就会把它 +// 写进缓存(3 天),此后每份报告的定位覆盖率都会莫名其妙变成 0。 +func TestLookupNetworkFailure(t *testing.T) { + srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) { + return nil, http.StatusBadGateway + }) + + r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{"8.8.4.4", "1.0.0.1"}) + + assert.Equal(t, int32(ipAPIAttempts), atomic.LoadInt32(calls), "必须重试,但只重试一次") + assert.Equal(t, 1, res.Requests) + assert.Equal(t, 1, res.Failures) + assert.Empty(t, res.Geo) + assert.Empty(t, res.Unresolved, "网络失败不能记成「这个 IP 没有位置」") +} + +// TestLookupRetrySucceeds 第一次抖动、第二次正常:不该丢数据。 +func TestLookupRetrySucceeds(t *testing.T) { + var attempt int32 + srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) { + if atomic.AddInt32(&attempt, 1) == 1 { + return nil, http.StatusGatewayTimeout + } + return []ipAPIResult{okIP(addrs[0], "日本", "JP", "Tokyo", 35.68, 139.69, "", "")}, 0 + }) + + r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{"133.242.0.1"}) + + require.Equal(t, 0, res.Failures) + assert.Equal(t, "日本", res.Geo["133.242.0.1"].Country) +} + +// TestLookupBatches 超过 100 个地址必须分批,而不是一次塞进去被拒。 +func TestLookupBatches(t *testing.T) { + srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) { + assert.LessOrEqual(t, len(addrs), ipAPIBatchSize, "单批不能超过 ip-api 的硬上限") + out := make([]ipAPIResult, 0, len(addrs)) + for _, a := range addrs { + out = append(out, okIP(a, "美国", "US", "Ashburn", 39.0, -77.5, "", "")) + } + return out, 0 + }) + + addrs := make([]string, 0, 150) + for i := 0; i < 150; i++ { + addrs = append(addrs, fmt.Sprintf("198.51.%d.%d", i/256, i%256)) + } + + r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), addrs) + + assert.Equal(t, int32(2), atomic.LoadInt32(calls)) + assert.Equal(t, 2, res.Requests) + assert.Len(t, res.Geo, 150) +} + +// TestLookupSkipsPrivate 私网 / 回环 / CGNAT 一律不外发:既不泄露内网结构, +// 也省下配额(这类地址查了也是空的)。 +func TestLookupSkipsPrivate(t *testing.T) { + srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) { + return nil, http.StatusInternalServerError + }) + + r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{ + "10.0.0.1", "192.168.1.1", "172.16.0.9", "127.0.0.1", + "100.64.3.4", "169.254.1.1", "::1", "fe80::1", "fd00::1", + }) + + assert.Equal(t, int32(0), atomic.LoadInt32(calls), "不可查询的地址不该产生任何请求") + assert.Equal(t, 0, res.Requests) + assert.Empty(t, res.Geo) + assert.Empty(t, res.Unresolved) +} + +// TestLookupNone provider = none:一个字节都不外发。 +func TestLookupNone(t *testing.T) { + srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) { + return nil, http.StatusInternalServerError + }) + r := New(Options{Provider: ProviderNone, HTTPClient: newTestClient(t, srv)}) + + assert.False(t, r.Enabled()) + res := r.Lookup(context.Background(), []string{"1.2.3.4"}) + assert.Equal(t, int32(0), atomic.LoadInt32(calls)) + assert.Empty(t, res.Geo) +} + +// TestLookupEmptyKeys 空输入不该发请求。 +func TestLookupEmptyKeys(t *testing.T) { + srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) { + return nil, http.StatusInternalServerError + }) + r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)}) + assert.Empty(t, r.Lookup(context.Background(), nil).Geo) + assert.Equal(t, int32(0), atomic.LoadInt32(calls)) +} + +// TestLookupIPv6Prefix IPv6 的 "/64" 缓存键要还原成基址去查。 +func TestLookupIPv6Prefix(t *testing.T) { + srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) { + require.Len(t, addrs, 1) + assert.Equal(t, "2001:4860:4860::", addrs[0], "带 /64 后缀的键必须剥掉后缀再查") + return []ipAPIResult{okIP(addrs[0], "美国", "US", "Mountain View", 37.4, -122.0, "Google", "AS15169 Google LLC")}, 0 + }) + + r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{"2001:4860:4860::/64"}) + + g, ok := res.Geo["2001:4860:4860::/64"] + require.True(t, ok, "结果键必须仍是原来的缓存键,不能变成查询用的地址") + assert.Equal(t, "US", g.CountryCode) + assert.Equal(t, "AS15169", g.ASN) +} + +// tencentServer 起一个假的腾讯 IP 定位接口。 +func tencentServer(t *testing.T, respond func(ip string) (tencentResult, int)) (*httptest.Server, *int32) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/ws/location/v1/ip" { + http.NotFound(w, req) + return + } + atomic.AddInt32(&calls, 1) + tr, code := respond(req.URL.Query().Get("ip")) + if code != 0 { + http.Error(w, "upstream failure", code) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tr) + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +// combinedServer 同时提供两条通道的测试服务器(auto 模式要两条都通)。 +func combinedServer(t *testing.T, ipapi func([]string) ([]ipAPIResult, int), tencent func(ip string) (tencentResult, int)) (*httptest.Server, *int32, *int32) { + t.Helper() + var ipCalls, txCalls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/batch": + atomic.AddInt32(&ipCalls, 1) + var addrs []string + if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + results, code := ipapi(addrs) + if code != 0 { + http.Error(w, "upstream failure", code) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(results) + case "/ws/location/v1/ip": + atomic.AddInt32(&txCalls, 1) + tr, code := tencent(req.URL.Query().Get("ip")) + if code != 0 { + http.Error(w, "upstream failure", code) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tr) + default: + http.NotFound(w, req) + } + })) + t.Cleanup(srv.Close) + return srv, &ipCalls, &txCalls +} + +func tencentOK(nation, province, city string, lat, lon float64) tencentResult { + var tr tencentResult + tr.Status = 0 + tr.Result.Location.Lat, tr.Result.Location.Lng = lat, lon + tr.Result.AdInfo.Nation = nation + tr.Result.AdInfo.Province = province + tr.Result.AdInfo.City = city + return tr +} + +// TestLookupAutoOnlyRefinesChina auto 模式只对国内 IP 调腾讯: +// 海外 IP 腾讯给不出省市,查了也是白花配额。 +func TestLookupAutoOnlyRefinesChina(t *testing.T) { + srv, _, txCalls := combinedServer(t, + func(addrs []string) ([]ipAPIResult, int) { + return []ipAPIResult{ + okIP(addrs[0], "中国", "CN", "", 0, 0, "", ""), + okIP(addrs[1], "美国", "US", "Ashburn", 39.0, -77.5, "", ""), + }, 0 + }, + func(ip string) (tencentResult, int) { + return tencentOK("中国", "北京市", "北京市", 39.9087, 116.3975), 0 + }) + + r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{"114.114.114.114", "45.32.0.1"}) + + assert.Equal(t, int32(1), atomic.LoadInt32(txCalls), "只该精修 1 个国内 IP") + + cn := res.Geo["114.114.114.114"] + assert.Equal(t, "北京市", cn.Region) + assert.Equal(t, "北京市", cn.City) + assert.Equal(t, "CN", cn.CountryCode) + assert.InDelta(t, 39.9087, cn.Lat, 1e-9) + assert.Equal(t, ProviderTencent, cn.Source) + + us := res.Geo["45.32.0.1"] + assert.Equal(t, "US", us.CountryCode) + assert.Equal(t, "Ashburn", us.City, "海外 IP 应保留 ip-api 的结果") + assert.Equal(t, ProviderIPAPI, us.Source) +} + +// TestLookupTencentRefineCap 精修数量必须有上限,否则周报会跑十几分钟。 +// +// 顺带守住一个容易踩的坑:精修必须按调用方给的顺序取前 N 个。报告是按命中 +// 次数从高到低传进来的,若这里按 map 遍历顺序取,每次运行精修到的 IP 都不一样, +// 配额会花在长尾地址上,「主要从哪个城市来」这个问题就答不准了。 +func TestLookupTencentRefineCap(t *testing.T) { + var mu sync.Mutex + var refined []string + srv, _, _ := combinedServer(t, + func(addrs []string) ([]ipAPIResult, int) { + out := make([]ipAPIResult, 0, len(addrs)) + for _, a := range addrs { + out = append(out, okIP(a, "中国", "CN", "", 0, 0, "", "")) + } + return out, 0 + }, + func(ip string) (tencentResult, int) { + mu.Lock() + refined = append(refined, ip) + mu.Unlock() + return tencentOK("中国", "广东省", "深圳市", 22.5, 114.0), 0 + }) + + addrs := []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4", "5.5.5.5"} + r := New(Options{ + Provider: ProviderAuto, TencentKey: "k", TencentRefineCap: 2, + HTTPClient: newTestClient(t, srv), + }) + res := r.Lookup(context.Background(), addrs) + + require.Len(t, refined, 2) + assert.ElementsMatch(t, []string{"1.1.1.1", "2.2.2.2"}, refined, + "必须精修前 2 个(最活跃的),而不是随机的 2 个") + + // 精修到的用腾讯结果 + assert.Equal(t, ProviderTencent, res.Geo["1.1.1.1"].Source) + // 没精修到的仍保留 ip-api 的国家 + assert.Equal(t, "CN", res.Geo["5.5.5.5"].CountryCode) + assert.Equal(t, ProviderIPAPI, res.Geo["5.5.5.5"].Source) +} + +// TestLookupTencentFailureNotCached 腾讯配额用尽 / key 无效属于「没查成」, +// 不能把整批 IP 记成「查不到」。ip-api 已经给出的结果也不能被抹掉。 +func TestLookupTencentFailureNotCached(t *testing.T) { + srv, _, _ := combinedServer(t, + func(addrs []string) ([]ipAPIResult, int) { + return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0 + }, + func(string) (tencentResult, int) { + var tr tencentResult + tr.Status = 311 + tr.Message = "key格式错误" + return tr, 0 + }) + + r := New(Options{Provider: ProviderAuto, TencentKey: "bad", HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{"114.114.114.114"}) + + assert.Equal(t, 1, res.Failures) + assert.Empty(t, res.Unresolved, "配额/key 问题不是「这个 IP 没数据」") + assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode, "ip-api 的结果不能被腾讯的失败抹掉") +} + +// TestLookupTencentNoData 状态成功但没有任何行政区划 = 真的没数据。 +// 但若 ip-api 已经定位到了,也不能反过来把这条记成「查不到」。 +func TestLookupTencentNoData(t *testing.T) { + srv, _, _ := combinedServer(t, + func(addrs []string) ([]ipAPIResult, int) { + return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0 + }, + func(string) (tencentResult, int) { return tencentOK("中国", "", "", 0, 0), 0 }) + + r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{"114.114.114.114"}) + + assert.Equal(t, 0, res.Failures) + assert.NotContains(t, res.Unresolved, "114.114.114.114", "已定位的 IP 不该被降级成查不到") + assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode) +} + +// TestTencentOnly 只用腾讯(provider = tencent):不经 ip-api,全部走腾讯。 +func TestTencentOnly(t *testing.T) { + srv, _, txCalls := combinedServer(t, + func([]string) ([]ipAPIResult, int) { + t.Error("provider = tencent 时不该请求 ip-api") + return nil, 0 + }, + func(string) (tencentResult, int) { + return tencentOK("中国", "浙江省", "杭州市", 30.27, 120.15), 0 + }) + + r := New(Options{Provider: ProviderTencent, TencentKey: "k", HTTPClient: newTestClient(t, srv)}) + res := r.Lookup(context.Background(), []string{"115.236.1.1"}) + + assert.Equal(t, int32(1), atomic.LoadInt32(txCalls)) + assert.Equal(t, "杭州市", res.Geo["115.236.1.1"].City) + assert.Equal(t, "CN", res.Geo["115.236.1.1"].CountryCode) +} + +// TestNewFallbacks 配置错误不能表现为「静默不工作」。 +func TestNewFallbacks(t *testing.T) { + // 只选 tencent 却没 key → 降级 ipapi + r := New(Options{Provider: ProviderTencent}) + assert.Equal(t, ProviderIPAPI, r.provider) + assert.True(t, r.Enabled()) + + // 空值 → auto + assert.Equal(t, ProviderAuto, New(Options{}).provider) + + // 不认识的值 → auto + assert.Equal(t, ProviderAuto, New(Options{Provider: "maxmind"}).provider) + + // none 不工作 + assert.False(t, New(Options{Provider: ProviderNone}).Enabled()) + + // refineCap 缺省 + assert.Equal(t, defaultTencentRefineCap, New(Options{}).refineCap) +} + +// TestDescribe 描述会写进报告,读者靠它判断坐标可信度。 +func TestDescribe(t *testing.T) { + assert.Contains(t, New(Options{Provider: ProviderAuto, TencentKey: "k"}).Describe(), "腾讯") + assert.Contains(t, New(Options{Provider: ProviderAuto}).Describe(), "未配置腾讯 key") + assert.Contains(t, New(Options{Provider: ProviderIPAPI}).Describe(), "ip-api") + assert.Contains(t, New(Options{Provider: ProviderNone}).Describe(), "未启用") +} + +// TestQueryAddr 缓存键还原。 +func TestQueryAddr(t *testing.T) { + cases := []struct { + key string + want string + ok bool + }{ + {"8.8.8.8", "8.8.8.8", true}, + {"2001:4860:4860::/64", "2001:4860:4860::", true}, + {"2001:4860:4860::8888", "2001:4860:4860::8888", true}, + {"10.1.2.3", "", false}, + {"::ffff:8.8.8.8", "8.8.8.8", true}, + {"not-an-ip", "", false}, + {"", "", false}, + } + for _, c := range cases { + got, ok := queryAddr(c.key) + assert.Equal(t, c.ok, ok, "key = %q", c.key) + assert.Equal(t, c.want, got, "key = %q", c.key) + } +} + +// TestNormalizeASN ASN 只留编号。 +func TestNormalizeASN(t *testing.T) { + assert.Equal(t, "AS15169", normalizeASN("AS15169 Google LLC")) + assert.Equal(t, "AS4134", normalizeASN(" AS4134 ")) + assert.Equal(t, "AS4134", normalizeASN("AS4134")) + assert.Equal(t, "", normalizeASN("")) +} + +// TestTencentCountryCode 不能把海外 IP 算成中国来源。 +func TestTencentCountryCode(t *testing.T) { + assert.Equal(t, "CN", tencentCountryCode("中国")) + assert.Equal(t, "", tencentCountryCode("中国香港")) + assert.Equal(t, "", tencentCountryCode("美国")) + assert.Equal(t, "", tencentCountryCode("")) +} diff --git a/internal/security/report/aggregate.go b/internal/security/report/aggregate.go new file mode 100644 index 0000000..ec64bdd --- /dev/null +++ b/internal/security/report/aggregate.go @@ -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 } diff --git a/internal/security/report/aggregate_test.go b/internal/security/report/aggregate_test.go new file mode 100644 index 0000000..aadbbc6 --- /dev/null +++ b/internal/security/report/aggregate_test.go @@ -0,0 +1,154 @@ +// 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 ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/tamakyi/TamaBox/internal/db" + "github.com/tamakyi/TamaBox/internal/security/geoip" +) + +// TestBuildMergesIPv6By64 覆盖「同一台机器在 /64 内轮换地址」的合并语义。 +// +// 这是报告最容易出错的地方:按精确地址统计会把一台机器算成 8 个来源, +// 于是「独立来源数」虚高、地理聚合里同一个城市出现多次。 +func TestBuildMergesIPv6By64(t *testing.T) { + loc := time.Local + start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) + period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} + + sources := []*db.HoneypotSourceAgg{ + {IP: "2001:db8:1:2::1", Hits: 10, Banned: 10, Creds: 2}, + {IP: "2001:db8:1:2::2", Hits: 5, Banned: 5, Creds: 1}, + {IP: "2001:db8:1:3::9", Hits: 1}, // 不同 /64,不能合并 + {IP: "203.0.113.7", Hits: 3, Banned: 3, Creds: 3}, + } + geo := map[string]geoip.Geo{ + "2001:db8:1:2::/64": {Country: "中国", CountryCode: "cn", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true}, + "2001:db8:1:3::/64": {Country: "中国", CountryCode: "CN", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true}, + "203.0.113.7": {Country: "美国", CountryCode: "US", Region: "California", City: "Fremont", Lat: 37.5, Lon: -121.9, HasCoords: true}, + } + + sum := Build(Input{ + Period: period, Sources: sources, Geo: geo, + Now: start.AddDate(0, 0, 7), + }) + + require.Equal(t, int64(19), sum.TotalHits) + // 四条精确地址压成三个来源(两个 /64 + 一个 IPv4): + // 若不合并会是 4,「同一个 C 段算成两台机器」的错觉就来自这里。 + require.Equal(t, 3, sum.UniqueSources) + + // 合并后的 /64 命中数应为 15,且排第一;展示用的 IP 取该 /64 里最活跃的 + // 那一台精确地址(::1 命中 10 次 > ::2 的 5 次)—— 报告里要能直接复制 + // 这个地址去查日志,所以不展示折后的前缀。 + require.Equal(t, "2001:db8:1:2::1", sum.Sources[0].IP) + require.Equal(t, int64(15), sum.Sources[0].Hits) + require.Equal(t, int64(3), sum.Sources[0].Creds) + + // 两个 /64 都在北京,城市聚合应当合成一个 16 次的气泡。 + require.Len(t, sum.Cities, 2) + require.Equal(t, "北京", sum.Cities[0].City) + require.Equal(t, int64(16), sum.Cities[0].Hits) + + // 国家码必须统一成大写(ip-api 与腾讯的大小写不一致,不统一会出现 + // 同一个国家被拆成 CN / cn 两行)。 + require.Len(t, sum.Countries, 2) + require.Equal(t, "CN", sum.Countries[0].Code) + require.Equal(t, "US", sum.Countries[1].Code) + require.Equal(t, int64(16), sum.Countries[0].Hits) +} + +// TestBuildUnlocated 覆盖「查不到位置」的显式统计。 +// +// 覆盖率必须是报告里的显式指标:一张 60% 覆盖的地图和 100% 的看起来一样。 +func TestBuildUnlocated(t *testing.T) { + loc := time.Local + start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) + period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} + + sum := Build(Input{ + Period: period, + Sources: []*db.HoneypotSourceAgg{ + {IP: "198.51.100.1", Hits: 7}, + {IP: "203.0.113.9", Hits: 3}, + }, + Geo: map[string]geoip.Geo{ + "203.0.113.9": {Country: "日本", CountryCode: "JP", Lat: 35.6, Lon: 139.7, HasCoords: true}, + }, + Now: start, + }) + + require.Equal(t, int64(10), sum.TotalHits) + require.Equal(t, int64(7), sum.UnlocatedHits) + // 未定位的那条没有坐标,不能进城市聚合(否则会画到 0,0)。 + require.Len(t, sum.Cities, 1) + require.Equal(t, "日本", sum.Cities[0].Country) + // 未定位的来源国家名必须是「未知」而不是空串 + require.Equal(t, unknownCountry, sum.Sources[0].Country) +} + +// TestBuildCloudSources 覆盖「云主机 vs 肉鸡」的区分。 +func TestBuildCloudSources(t *testing.T) { + loc := time.Local + start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) + period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} + + sum := Build(Input{ + Period: period, + Sources: []*db.HoneypotSourceAgg{ + {IP: "203.0.113.1", Hits: 1, Provider: "微软 Azure(云厂商)"}, + {IP: "203.0.113.2", Hits: 1, Provider: "未识别(普通网络)"}, + {IP: "203.0.113.3", Hits: 1, Provider: "识别失败"}, + {IP: "203.0.113.4", Hits: 1, Provider: ""}, + }, + Now: start, + }) + // 只有第一条算云主机:剩下三种都是「不知道」,不能当成云。 + require.Equal(t, 1, sum.CloudSources) +} + +// TestBuildEmpty 覆盖空窗口:报告仍要能生成(「这周没人打」是个结论)。 +func TestBuildEmpty(t *testing.T) { + loc := time.Local + start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) + period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} + + sum := Build(Input{Period: period, Now: start}) + require.True(t, sum.Empty) + require.Equal(t, int64(0), sum.TotalHits) + // 每一天都要有 0 值占位,否则趋势图会把「无数据」画成断档。 + require.Len(t, sum.Days, 7) +} + +// TestBuildDailySeries 覆盖每日趋势:无命中的日子必须补 0。 +func TestBuildDailySeries(t *testing.T) { + loc := time.Local + start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) + period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} + + stamps := []time.Time{ + start.Add(2 * time.Hour), // 10-19 + start.Add(3 * time.Hour), // 10-19 + start.AddDate(0, 0, 4).Add(time.Hour), // 10-23 + // 边界外:下一周期的第一天第一秒不能算进来(左闭右开) + period.End, + } + sum := Build(Input{Period: period, Timestamps: stamps, Now: start}) + + require.Len(t, sum.Days, 7) + require.Equal(t, int64(2), sum.Days[0].Hits) + require.Equal(t, int64(0), sum.Days[1].Hits) + require.Equal(t, int64(1), sum.Days[4].Hits) + require.Equal(t, int64(2), sum.MaxDay) + // TotalHits 由「来源聚合」得出,与「时间戳聚合」是两条独立输入; + // 本用例只给了时间戳,所以 TotalHits 为 0。真实调用里两者同窗口同表, + // 数值必定一致(见 internal/cmd/report.go 的 buildSecurityReport)。 + require.Equal(t, int64(0), sum.TotalHits) +} diff --git a/internal/security/report/countryname.go b/internal/security/report/countryname.go new file mode 100644 index 0000000..da3182a --- /dev/null +++ b/internal/security/report/countryname.go @@ -0,0 +1,135 @@ +// 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 "strings" + +// countryNames 常见国家/地区的中文显示名。 +// +// 报告是给中文读者看的,表格里一列「United States」一列「China」看着像没做完。 +// 只覆盖实际可能出现的来源国(攻击流量高度集中在少数国家/地区 + 一批云机房 +// 所在地),查不到的直接用 IP 库给的英文名 —— 不做「猜」,也不引入几百行的 +// 全量对照表。 +var countryNames = map[string]string{ + "CN": "中国", + "HK": "中国香港", + "MO": "中国澳门", + "TW": "中国台湾", + "US": "美国", + "JP": "日本", + "KR": "韩国", + "SG": "新加坡", + "MY": "马来西亚", + "TH": "泰国", + "VN": "越南", + "PH": "菲律宾", + "ID": "印度尼西亚", + "IN": "印度", + "PK": "巴基斯坦", + "BD": "孟加拉国", + "LK": "斯里兰卡", + "NP": "尼泊尔", + "KH": "柬埔寨", + "MM": "缅甸", + "MN": "蒙古", + "KZ": "哈萨克斯坦", + "UZ": "乌兹别克斯坦", + "AE": "阿联酋", + "SA": "沙特阿拉伯", + "IL": "以色列", + "TR": "土耳其", + "IQ": "伊拉克", + "IR": "伊朗", + "QA": "卡塔尔", + "KW": "科威特", + "JO": "约旦", + "LB": "黎巴嫩", + "RU": "俄罗斯", + "UA": "乌克兰", + "BY": "白俄罗斯", + "PL": "波兰", + "DE": "德国", + "FR": "法国", + "GB": "英国", + "IE": "爱尔兰", + "NL": "荷兰", + "BE": "比利时", + "LU": "卢森堡", + "CH": "瑞士", + "AT": "奥地利", + "CZ": "捷克", + "SK": "斯洛伐克", + "HU": "匈牙利", + "RO": "罗马尼亚", + "BG": "保加利亚", + "GR": "希腊", + "IT": "意大利", + "ES": "西班牙", + "PT": "葡萄牙", + "SE": "瑞典", + "NO": "挪威", + "DK": "丹麦", + "FI": "芬兰", + "EE": "爱沙尼亚", + "LV": "拉脱维亚", + "LT": "立陶宛", + "IS": "冰岛", + "RS": "塞尔维亚", + "HR": "克罗地亚", + "SI": "斯洛文尼亚", + "MD": "摩尔多瓦", + "CA": "加拿大", + "MX": "墨西哥", + "BR": "巴西", + "AR": "阿根廷", + "CL": "智利", + "CO": "哥伦比亚", + "PE": "秘鲁", + "VE": "委内瑞拉", + "EC": "厄瓜多尔", + "UY": "乌拉圭", + "PY": "巴拉圭", + "BO": "玻利维亚", + "PA": "巴拿马", + "CR": "哥斯达黎加", + "AU": "澳大利亚", + "NZ": "新西兰", + "ZA": "南非", + "EG": "埃及", + "NG": "尼日利亚", + "KE": "肯尼亚", + "MA": "摩洛哥", + "DZ": "阿尔及利亚", + "TN": "突尼斯", + "GH": "加纳", + "ET": "埃塞俄比亚", + "TZ": "坦桑尼亚", + "SC": "塞舌尔", + "MU": "毛里求斯", + "CY": "塞浦路斯", + "MT": "马耳他", + "AL": "阿尔巴尼亚", + "MK": "北马其顿", + "BA": "波黑", + "GE": "格鲁吉亚", + "AM": "亚美尼亚", + "AZ": "阿塞拜疆", + "AF": "阿富汗", +} + +// countryName 返回国家/地区的显示名;没收录的退回 IP 库给的英文名。 +// +// 港澳台显式写成「中国香港 / 中国澳门 / 中国台湾」:它们是中国的组成部分, +// 在报告里跟「United States」并列成同级「国家」是不对的。这类 IP 库通常 +// 单独编码 HK/MO/TW,所以必须在展示层统一,而不是指望数据源。 +func countryName(code, fallback string) string { + if n, ok := countryNames[strings.ToUpper(strings.TrimSpace(code))]; ok { + return n + } + if fallback != "" { + return fallback + } + return "未知" +} diff --git a/internal/security/report/geo.go b/internal/security/report/geo.go new file mode 100644 index 0000000..9ee65a3 --- /dev/null +++ b/internal/security/report/geo.go @@ -0,0 +1,156 @@ +// 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" + "time" + + "github.com/sirupsen/logrus" + + "github.com/tamakyi/TamaBox/internal/db" + "github.com/tamakyi/TamaBox/internal/security/geoip" +) + +// 缓存 TTL。 +// +// 成功结果留 90 天:一个 IP 的国家/省份几乎不会变,城市级偶尔变, +// 90 天足够新。失败结果只留 3 天:失败多为临时原因(对方 API 超时、 +// 限流),留久了等于永久放弃这个地址。 +const ( + geoSuccessTTL = 90 * 24 * time.Hour + geoFailureTTL = 3 * 24 * time.Hour +) + +// GeoResolver 是定位器的最小接口(*geoip.Resolver 实现它)。 +// +// 用接口而不是具体类型:这个函数的核心是缓存语义(TTL、失败短期不重试、 +// 写回),必须能在不联网、不依赖真实 IP 库的情况下测到。 +type GeoResolver interface { + Lookup(ctx context.Context, keys []string) geoip.Result + Enabled() bool +} + +// ResolveWithCache 解析一批 IP 的地理位置,优先用缓存。 +// +// keys 是 shared.LimitKey 的产物,顺序会被保留用于「腾讯精修取前 N 个」—— +// 调用方应按命中次数从高到低传入,这样配额优先花在最活跃的来源上。 +// +// ⚠️ 写缓存的判定是这里全部的价值所在,务必分清三种结果: +// - Result.Geo[key] 存在 → 查到了,写成功缓存(90 天) +// - Result.Unresolved[key] → 服务明确说没有这个地址的数据,写失败缓存(3 天), +// 免得每份报告都去重试一个注定查不到的地址 +// - 两个 map 里都没有 → 这次请求本身没成功(超时 / 限流 / 网关 5xx)。 +// **绝不写缓存**。早先的实现在这里也写 Failed,结果是一次网络抖动就把整批 +// IP 钉成「查不到」三天,此后每份报告的定位覆盖率都莫名其妙变成 0, +// 而且日志里没有任何线索指向真正的原因。 +func ResolveWithCache(ctx context.Context, cache db.IPGeoStore, resolver GeoResolver, keys []string) map[string]geoip.Geo { + out := make(map[string]geoip.Geo, len(keys)) + if len(keys) == 0 { + return out + } + + cached, err := cache.GetMany(ctx, keys) + if err != nil { + // 缓存读失败不该让报告失败:退化成「全部现查」, + // 代价是可能多花点配额,但报告照出。 + logrus.WithError(err).Warn("读取 IP 地理缓存失败,本次将全部重新查询") + cached = nil + } + + now := time.Now() + var misses []string + seen := make(map[string]bool, len(keys)) + for _, key := range keys { + if seen[key] { + continue + } + seen[key] = true + + rec, ok := cached[key] + if ok && fresh(rec, now) { + if !rec.Failed { + out[key] = geoip.Geo{ + Country: rec.Country, + CountryCode: rec.CountryCode, + Region: rec.Region, + City: rec.City, + ISP: rec.ISP, + ASN: rec.ASN, + Lat: rec.Lat, + Lon: rec.Lon, + HasCoords: rec.HasCoords, + Source: rec.Source, + } + } + // 命中但 Failed:明确记为「查不到」,不再重试,也不进 out。 + continue + } + misses = append(misses, key) + } + + if len(misses) == 0 || !resolver.Enabled() { + return out + } + + resolved := resolver.Lookup(ctx, misses) + if resolved.Failures > 0 { + logrus.WithFields(logrus.Fields{ + "keys": len(misses), + "requests": resolved.Requests, + "failures": resolved.Failures, + "located": len(resolved.Geo), + }).Warn("IP 定位请求部分失败:失败的那些不进缓存,下次报告会重新查询") + } + + // 写回缓存:成功的与「服务明确说没有」的都写,网络失败的跳过。 + records := make([]*db.IPGeoRecord, 0, len(misses)) + for _, key := range misses { + if g, ok := resolved.Geo[key]; ok { + out[key] = g + records = append(records, &db.IPGeoRecord{ + IPKey: key, + Country: g.Country, + CountryCode: g.CountryCode, + Region: g.Region, + City: g.City, + ISP: g.ISP, + ASN: g.ASN, + Lat: g.Lat, + Lon: g.Lon, + HasCoords: g.HasCoords, + Source: g.Source, + ResolvedAt: now, + }) + continue + } + if resolved.Unresolved[key] { + records = append(records, &db.IPGeoRecord{ + IPKey: key, + ResolvedAt: now, + Failed: true, + }) + } + } + if len(records) == 0 { + return out + } + if err := cache.UpsertMany(ctx, records); err != nil { + logrus.WithError(err).Warn("写入 IP 地理缓存失败(不影响本次报告)") + } + return out +} + +// fresh 判断缓存记录是否还在有效期内。 +func fresh(rec *db.IPGeoRecord, now time.Time) bool { + if rec == nil { + return false + } + ttl := geoSuccessTTL + if rec.Failed { + ttl = geoFailureTTL + } + return now.Sub(rec.ResolvedAt) < ttl +} diff --git a/internal/security/report/geo_test.go b/internal/security/report/geo_test.go new file mode 100644 index 0000000..be924b2 --- /dev/null +++ b/internal/security/report/geo_test.go @@ -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) +} diff --git a/internal/security/report/html.go b/internal/security/report/html.go new file mode 100644 index 0000000..f9fa39b --- /dev/null +++ b/internal/security/report/html.go @@ -0,0 +1,391 @@ +// 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 ( + "encoding/base64" + "fmt" + "html" + "math" + "os" + "path/filepath" + "strings" + "time" +) + +// HTMLOptions 渲染选项。 +type HTMLOptions struct { + // TencentMapKey 腾讯位置服务 key。留空则报告里只有离线 SVG 地图, + // 并附一句「需要可交互底图请配置 ip_geo_tencent_key」。 + TencentMapKey string +} + +// tencentKeyPlaceholder 未配置 key 时写进页面的占位符。 +// +// 按地图合规要求,代码里绝不预置任何可用的 key;占位符要让拿到报告的人 +// 一眼看出「这里需要你自己申请」,而不是以为地图坏了。 +const tencentKeyPlaceholder = "请前往 lbs.qq.com 申请 key 后填入 [security] ip_geo_tencent_key" + +// RenderHTML 生成完整的自包含报告页。 +// +// 自包含的含义:不依赖 site 的 CSS、不依赖任何外链图片(腾讯地图那一块是 +// 可选的在线增强,其余部分断网也能看)。 +func RenderHTML(sum *Summary, opts HTMLOptions) string { + var b strings.Builder + + title := sum.Period.Title() + b.WriteString(``) + b.WriteString(``) + fmt.Fprintf(&b, `%s`, html.EscapeString(title)) + b.WriteString(pageCSS) + b.WriteString(`
`) + + // 头部 + fmt.Fprintf(&b, `

%s

统计窗口 %s · 生成于 %s

`, + html.EscapeString(title), html.EscapeString(sum.Period.Label()), + sum.GeneratedAt.Format("2006-01-02 15:04")) + + if sum.Empty { + b.WriteString(`
本期没有捕获到任何蜜罐命中 —— 这本身是个好消息,但也请确认蜜罐仍然开启(TamaBox honeypot paths)。
`) + b.WriteString(`
`) + return b.String() + } + + // KPI + b.WriteString(`
`) + writeKPI(&b, "蜜罐命中", fmt.Sprint(sum.TotalHits), trendNote(sum.TotalHits, sum.PrevHits)) + writeKPI(&b, "独立来源", fmt.Sprint(sum.UniqueSources), fmt.Sprintf("覆盖 %d 个国家和地区", sum.UniqueCountry)) + writeKPI(&b, "已拦截", fmt.Sprint(sum.BannedHits), pct(sum.BannedHits, sum.TotalHits)) + writeKPI(&b, "真实破解尝试", fmt.Sprint(sum.CredHits), "提交了账号密码") + writeKPI(&b, "云主机来源", fmt.Sprint(sum.CloudSources), fmt.Sprintf("占比 %s", pctInt(int64(sum.CloudSources), int64(sum.UniqueSources)))) + b.WriteString(`
`) + + // 地图 + b.WriteString(`

来源分布

`) + b.WriteString(`

气泡面积与命中次数成正比。地图上的点是 IP 库给出的城市级定位,精度到城市,不代表对方的真实街道位置。

`) + b.WriteString(`
`) + b.WriteString(RenderSVG(sum)) + b.WriteString(`
`) + b.WriteString(`

⚠️ 本图只画经纬网与气泡,不含任何国界。国界、台湾与南海诸岛的正确绘制须符合国家标准,自绘底图容易出错,因此这里不做底图;需要真实底图请看下方的腾讯地图版本。

`) + b.WriteString(`
`) + + // 腾讯可交互地图 + b.WriteString(`

可交互地图

`) + b.WriteString(renderTencentMap(sum, opts.TencentMapKey)) + b.WriteString(`
`) + + // 国家排行 + b.WriteString(`

国家 / 地区排行

`) + b.WriteString(``) + for i, c := range sum.Countries { + if i >= 20 { + break + } + fmt.Fprintf(&b, ``, + i+1, html.EscapeString(c.Name), html.EscapeString(c.Code), c.Hits, pctFloat(c.Share), c.Sources) + } + b.WriteString(`
#国家 / 地区命中占比独立来源
%d%s %s%d%s%d
`) + + // 城市排行 + if len(sum.Cities) > 0 { + b.WriteString(`

城市排行(前 20)

`) + b.WriteString(``) + for i, c := range sum.Cities { + if i >= 20 { + break + } + city := c.City + if city == "" { + city = c.Region + } + if city == "" { + city = "(未知城市)" + } + fmt.Fprintf(&b, ``, + i+1, html.EscapeString(city), html.EscapeString(c.Country), c.Hits, c.Sources) + } + b.WriteString(`
#城市国家命中独立来源
%d%s%s%d%d
`) + } + + // 来源明细 + b.WriteString(`

来源明细(前 50)

`) + b.WriteString(``) + for i, s := range sum.Sources { + if i >= 50 { + break + } + provider := s.Provider + if provider == "" { + provider = "—" + } + isp := s.ISP + if s.ASN != "" { + if isp != "" { + isp += " · " + } + isp += s.ASN + } + if isp == "" { + isp = "—" + } + fmt.Fprintf(&b, ``, + html.EscapeString(s.IP), html.EscapeString(s.Location()), + html.EscapeString(isp), html.EscapeString(provider), + s.Hits, s.Creds, s.Banned) + } + b.WriteString(`
IP位置运营商 / ASN网络归属命中破解拦截
%s%s%s%s%d%d%d
`) + if len(sum.Sources) >= 50 { + fmt.Fprintf(&b, `

共 %d 个独立来源,此处只列命中次数最高的 50 个。完整明细:TamaBox honeypot stats --days %d

`, + sum.UniqueSources, int(sum.Period.End.Sub(sum.Period.Start).Hours()/24)) + } + b.WriteString(`
`) + + // 路径 + if len(sum.Paths) > 0 { + b.WriteString(`

被探测路径

`) + b.WriteString(``) + for i, p := range sum.Paths { + if i >= 20 { + break + } + fmt.Fprintf(&b, ``, + i+1, html.EscapeString(p.Path), p.Hits) + } + b.WriteString(`
#路径次数
%d%s%d
`) + } + + // 趋势 + if len(sum.Days) > 1 { + b.WriteString(`

每日趋势

`) + b.WriteString(``) + for _, d := range sum.Days { + w := 0.0 + if sum.MaxDay > 0 { + w = float64(d.Hits) / float64(sum.MaxDay) * 100 + } + // 宽度为 0 的日子也给 1px,否则读起来像「数据缺失」而不是「零」。 + barW := math.Max(w, 0.6) + fmt.Fprintf(&b, ``, + d.Day.Format("01-02"), barW, d.Hits) + } + b.WriteString(`
%s%d
`) + } + + // 口径说明 + b.WriteString(`

口径与说明

    `) + fmt.Fprintf(&b, `
  • 地理定位数据源:%s。
  • `, html.EscapeString(sum.ResolverDesc)) + fmt.Fprintf(&b, `
  • 未能定位的命中:%d 次(占 %s)。这些 IP 在国内外的 IP 库里都没有城市级记录,通常是被回收中的地址段或运营商级 NAT。
  • `, + sum.UnlocatedHits, pct(sum.UnlocatedHits, sum.TotalHits)) + b.WriteString(`
  • 「破解」指对该次请求提交了账号密码字段;纯路径探测不计入。捕获到的账号密码本身不写进本报告,存放在服务器的取证目录(TamaBox honeypot hits 查看)。
  • `) + b.WriteString(`
  • 「网络归属」来自离线指纹(cdncheck),判定的是云厂商 / CDN / WAF,不是地理位置。显示「未识别(普通网络)」不代表没定位到,两者是不同的维度。
  • `) + b.WriteString(`
  • 独立来源按封禁口径聚合:IPv4 精确到地址,IPv6 折叠到 /64 —— 攻击者常在一个 /64 内轮换地址,按精确地址会把一台机器算成几十个。
  • `) + b.WriteString(`
`) + + fmt.Fprintf(&b, `
由 TamaBox 蜜罐自动生成 · %s
`, sum.GeneratedAt.Format("2006-01-02 15:04:05")) + b.WriteString(``) + return b.String() +} + +// renderTencentMap 生成腾讯地图区块。 +// +// 合规要点(与 geo-map-compliance-guard 一致): +// - 只用持牌厂商的底图(腾讯位置服务),绝不引 Google/Bing/OSM/Mapbox; +// - 代码里不预置任何可用 key,未配置时给出申请指引; +// - 地图展示的是攻击者 IP 的城市级定位,用于内部安全复盘,不对外发布。 +func renderTencentMap(sum *Summary, key string) string { + key = strings.TrimSpace(key) + if key == "" { + return fmt.Sprintf(`
未配置腾讯地图 key,本节跳过。
如需可缩放的真实底图,%s。
`, + html.EscapeString(tencentKeyPlaceholder)) + } + + pts := bubbles(sum) + if len(pts) > 120 { + pts = pts[:120] // 地图上挤 500 个点既卡又看不清 + } + maxHits := maxCityHits(pts) + + var geo strings.Builder + geo.WriteString("[") + for i, p := range pts { + if i > 0 { + geo.WriteString(",") + } + // 腾讯地图用 GCJ-02。ip-api 给的是 WGS-84(与 GCJ-02 差几百米), + // 在城市级缩放上无影响;放大到街道时才有偏差。 + fmt.Fprintf(&geo, `{"id":"p%d","styleId":"s%d","position":[%f,%f],"title":"%s %d 次"}`, + i, sizeClass(p.Hits, maxHits), p.Lat, p.Lon, + jsEscape(p.City+p.Country), p.Hits) + } + geo.WriteString("]") + + return fmt.Sprintf(`
+
+ +`, + html.EscapeString(key), geo.String(), len(pts)) +} + +// sizeClass 按命中数把气泡分成 5 档(地图标记的尺寸样式只有有限几档)。 +func sizeClass(hits, maxHits int64) int { + if maxHits <= 0 { + return 0 + } + r := float64(hits) / float64(maxHits) + switch { + case r > 0.8: + return 4 + case r > 0.5: + return 3 + case r > 0.25: + return 2 + case r > 0.08: + return 1 + default: + return 0 + } +} + +// jsEscape 转义要嵌进 JS 字符串字面量的文本。 +// 先做 HTML 转义(防 逃逸),再转义引号与反斜杠。 +func jsEscape(s string) string { + s = html.EscapeString(s) + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + return s +} + +func writeKPI(b *strings.Builder, label, value, note string) { + b.WriteString(`
`) + fmt.Fprintf(b, `
%s
%s
`, html.EscapeString(label), html.EscapeString(value)) + if note != "" { + fmt.Fprintf(b, `
%s
`, html.EscapeString(note)) + } + b.WriteString(`
`) +} + +// trendNote 给出与上一周期的对比。 +func trendNote(cur, prev int64) string { + if prev <= 0 { + return "上期无数据" + } + delta := float64(cur-prev) / float64(prev) * 100 + arrow := "↑" + if delta < 0 { + arrow = "↓" + } + return fmt.Sprintf("较上期 %s %.0f%%(上期 %d)", arrow, math.Abs(delta), prev) +} + +func pct(v, total int64) string { + if total <= 0 { + return "—" + } + return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100) +} + +func pctInt(v, total int64) string { return pct(v, total) } + +func pctFloat(f float64) string { return fmt.Sprintf("%.1f%%", f*100) } + +// WriteFiles 把报告落盘,返回 HTML 与 PNG 的路径。 +// +// 目录按 0700 创建:报告里有攻击者 IP 明细,不是可以随便给别的用户读的文件。 +func WriteFiles(sum *Summary, dir string, opts HTMLOptions) (htmlPath, pngPath string, err error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", err + } + base := filepath.Join(dir, sum.Period.Filename()) + + htmlPath = base + ".html" + if err = os.WriteFile(htmlPath, []byte(RenderHTML(sum, opts)), 0o600); err != nil { + return "", "", err + } + + png, err := RenderPNG(sum) + if err != nil { + return htmlPath, "", err + } + pngPath = base + ".png" + if err = os.WriteFile(pngPath, png, 0o600); err != nil { + return htmlPath, "", err + } + return htmlPath, pngPath, nil +} + +// PNGDataURI 把地图转成 data URI(备用:某些邮件客户端对 CID 附件不友好时 +// 可以退化为内联 data URI —— 代价是邮件体积变大且部分客户端会拦)。 +func PNGDataURI(sum *Summary) (string, error) { + b, err := RenderPNG(sum) + if err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(b), nil +} + +// pageCSS 报告页样式。刻意内联:报告的 HTML 会被单独转发、离线打开, +// 不能依赖站点的静态资源(那些 URL 在别人机器上打不开)。 +const pageCSS = `` + +// FormatTime 供模板层统一时间格式。 +func FormatTime(t time.Time) string { return t.Format("2006-01-02 15:04") } diff --git a/internal/security/report/mapdraw.go b/internal/security/report/mapdraw.go new file mode 100644 index 0000000..29d314e --- /dev/null +++ b/internal/security/report/mapdraw.go @@ -0,0 +1,476 @@ +// 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 ( + "bytes" + "fmt" + "html" + "image" + "image/color" + "image/draw" + "image/png" + "math" + "sort" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +// 画布尺寸。2:1 是等距圆柱(equirectangular)投影的天然比例: +// 经度 360°、纬度 180°,正好铺满矩形。 +const ( + mapWidth = 960 + mapHeight = 480 +) + +// 配色。刻意用浅色:邮件模板是白底,深色地图嵌进去像个补丁。 +var ( + colOcean = color.RGBA{0xF1, 0xF5, 0xF9, 0xFF} + colGrid = color.RGBA{0xCB, 0xD5, 0xE1, 0xFF} + colGridKey = color.RGBA{0x94, 0xA3, 0xB8, 0xFF} + colBubble = color.RGBA{0xEF, 0x44, 0x44, 0xFF} + colRim = color.RGBA{0xB9, 0x1C, 0x1C, 0xFF} + colText = color.RGBA{0x33, 0x41, 0x55, 0xFF} +) + +// project 把经纬度映射到画布坐标(等距圆柱投影)。 +// +// ⚠️ 这里不画任何海岸线或国界。原因不是偷懒:国界、台湾与南海诸岛的绘制 +// 必须符合国家标准,自绘底图极易画错,而画错地图是比「图不好看」严重得多 +// 的问题。需要真实底图时用腾讯地图(本包另出的 HTML 版带它)。 +// 经纬网 + 气泡已经足以回答「攻击主要来自哪个半球/哪个区域」。 +func project(lat, lon float64) (x, y float64) { + x = (lon + 180) / 360 * mapWidth + y = (90 - lat) / 180 * mapHeight + return x, y +} + +// bubbleRadius 按命中数相对最大值的平方根缩放。 +// +// 用 sqrt 而不是线性:几次命中和上千次命中如果按线性画,前者会小到看不见; +// 面积正比于数值是气泡图的通行做法(人眼比较的是面积而不是半径)。 +func bubbleRadius(hits, maxHits int64) float64 { + if maxHits <= 0 { + return 4 + } + ratio := math.Sqrt(float64(hits) / float64(maxHits)) + return 3.5 + 17*ratio +} + +// bubbles 从城市聚合里挑出可画的点,按命中数降序。 +func bubbles(sum *Summary) []CityRow { + pts := make([]CityRow, 0, len(sum.Cities)) + for _, c := range sum.Cities { + // 坐标越界(IP 库偶尔给出 0,0 或越界值)直接丢弃: + // 画到几内亚湾比不画更误导。 + if math.IsNaN(c.Lat) || math.IsNaN(c.Lon) || + c.Lat < -90 || c.Lat > 90 || c.Lon < -180 || c.Lon > 180 { + continue + } + if c.Lat == 0 && c.Lon == 0 { + continue + } + pts = append(pts, c) + } + sort.Slice(pts, func(i, j int) bool { + if pts[i].Hits != pts[j].Hits { + return pts[i].Hits > pts[j].Hits + } + return pts[i].City < pts[j].City + }) + return pts +} + +// maxCityHits 返回城市命中数的最大值(气泡缩放基准)。 +func maxCityHits(pts []CityRow) int64 { + var m int64 + for _, p := range pts { + if p.Hits > m { + m = p.Hits + } + } + return m +} + +// graticule 经纬网:经度每 30°、纬度每 30° 一条,赤道与本初子午线加粗。 +func graticule() (xs, ys []float64) { + for lon := -180.0; lon <= 180; lon += 30 { + xs = append(xs, lon) + } + for lat := -90.0; lat <= 90; lat += 30 { + ys = append(ys, lat) + } + return xs, ys +} + +// labelBox 已放好的标签占位框。 +type labelBox struct { + x0, y0, x1, y1 float64 +} + +// labelPacker 给标签找不重叠的位置。 +// +// 为什么必须做:同一个都市圈的不同城市,经纬度只差 0.01°,标签会精确地压在 +// 一起,两行字叠成一串谁也读不出的字符。实测出现过「AU 305」这种由 +// 「AU 55」与「AU 30」叠出来的假数字 —— 这比不标更糟:读者会把它当真实数据。 +// 宁可少标几个,也不能让图上出现一个读错就是错的数字。 +type labelPacker struct { + placed []labelBox + width, height float64 +} + +func newLabelPacker(width, height float64) *labelPacker { + return &labelPacker{width: width, height: height} +} + +// place 在气泡右侧找位置,放不下就换左侧,两侧都占则放弃。 +// 返回标签框左上角坐标。 +func (p *labelPacker) place(bx, by, r, textW, textH float64) (x, y float64, ok bool) { + top := by - textH/2 + for _, cx := range []float64{bx + r + 3, bx - r - 3 - textW} { + if cx < 2 || cx+textW > p.width-2 || top < 1 || top+textH > p.height-1 { + continue + } + box := labelBox{cx - 2, top - 1, cx + textW + 2, top + textH + 1} + if p.collides(box) { + continue + } + p.placed = append(p.placed, box) + return cx, top, true + } + return 0, 0, false +} + +func (p *labelPacker) collides(b labelBox) bool { + for _, o := range p.placed { + if b.x0 < o.x1 && o.x0 < b.x1 && b.y0 < o.y1 && o.y0 < b.y1 { + return true + } + } + return false +} + +// 经纬度刻度的取值。只标每 60° / 30°,标满会把气泡和标签挤没。 +var ( + tickLons = []float64{-180, -120, -60, 0, 60, 120, 180} + tickLats = []float64{-60, -30, 0, 30, 60} +) + +// latLabel 把纬度格式化成 30N / 30S 这样的刻度。 +func latLabel(lat float64) string { + switch { + case lat == 0: + return "0" + case lat > 0: + return fmt.Sprintf("%.0fN", lat) + default: + return fmt.Sprintf("%.0fS", -lat) + } +} + +// RenderSVG 生成内联 SVG 世界气泡图。用于 HTML 报告附件(浏览器里看)。 +func RenderSVG(sum *Summary) string { + pts := bubbles(sum) + maxHits := maxCityHits(pts) + lons, lats := graticule() + + var b bytes.Buffer + fmt.Fprintf(&b, ``, mapWidth, mapHeight) + fmt.Fprintf(&b, ``, mapWidth, mapHeight) + + // 经纬网 + for _, lon := range lons { + x, _ := project(0, lon) + stroke := "#CBD5E1" + w := "0.6" + if lon == 0 { + stroke = "#94A3B8" + w = "1" + } + fmt.Fprintf(&b, ``, x, x, mapHeight, stroke, w) + } + for _, lat := range lats { + _, y := project(lat, 0) + stroke := "#CBD5E1" + w := "0.6" + if lat == 0 { + stroke = "#94A3B8" + w = "1" + } + fmt.Fprintf(&b, ``, y, mapWidth, y, stroke, w) + } + + // 经纬度刻度:没有底图时这是读者唯一的方向参照,不能省。 + for _, lon := range tickLons { + x, _ := project(0, lon) + if x < 12 { + x = 12 + } + if x > mapWidth-12 { + x = mapWidth - 12 + } + fmt.Fprintf(&b, `%s`, + x, mapHeight-4, lonLabel(lon)) + } + for _, lat := range tickLats { + _, y := project(lat, 0) + fmt.Fprintf(&b, `%s`, + y+3, latLabel(lat)) + } + + // 气泡 + for _, p := range pts { + x, y := project(p.Lat, p.Lon) + r := bubbleRadius(p.Hits, maxHits) + fmt.Fprintf(&b, `%s · %s:%d 次命中(%d 个来源)`, + x, y, r, html.EscapeString(p.City), html.EscapeString(p.Country), p.Hits, p.Sources) + } + + // 标注前 8 个城市:放过大的气泡,标签不重叠才画(理由见 labelPacker)。 + packer := newLabelPacker(mapWidth, mapHeight) + for i, p := range pts { + if i >= 8 { + break + } + x, y := project(p.Lat, p.Lon) + r := bubbleRadius(p.Hits, maxHits) + label := cityLabel(p) + tx, ty, ok := packer.place(x, y, r, svgTextWidth(label), 12) + if !ok { + continue + } + fmt.Fprintf(&b, `%s`, + tx, ty+10, html.EscapeString(label)) + } + b.WriteString(``) + return b.String() +} + +// cityLabel 优先用城市名,退到省/州,最后退到国家名。 +func cityLabel(p CityRow) string { + if p.City != "" { + return fmt.Sprintf("%s %d", p.City, p.Hits) + } + if p.Region != "" { + return fmt.Sprintf("%s %d", p.Region, p.Hits) + } + return fmt.Sprintf("%s %d", p.Country, p.Hits) +} + +// svgTextWidth 粗估一行文字在 11px 下的宽度。 +// CJK 按全宽算、其余按 0.62 倍字宽算 —— 只用于判断是否会撞在一起, +// 精确到像素没有意义(何况最终字体由浏览器决定)。 +func svgTextWidth(s string) float64 { + var w float64 + for _, r := range s { + if r > 0x2E80 { + w += 11 + } else { + w += 6.8 + } + } + return w +} + +// lonLabel 把经度格式化成 90°E / 120°W 这样的刻度。 +func lonLabel(lon float64) string { + switch { + case lon == 0: + return "0" + case lon > 0: + return fmt.Sprintf("%.0fE", lon) + default: + return fmt.Sprintf("%.0fW", -lon) + } +} + +// RenderPNG 生成邮件正文用的位图世界气泡图。 +// +// 为什么要位图:邮件客户端不执行 JS,且 Gmail / Outlook 会剥离内联 SVG。 +// 只有以 CID 内嵌的 PNG 能保证「打开邮件就看得到地图」。 +// +// 图上文字只能是 ASCII(Go 标准库里唯一的无依赖位图字体 basicfont 只有 +// 7x13 的拉丁字形),所以标签用国家码 + 次数,中文说明放在邮件正文里。 +func RenderPNG(sum *Summary) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, mapWidth, mapHeight)) + draw.Draw(img, img.Bounds(), &image.Uniform{colOcean}, image.Point{}, draw.Src) + + lons, lats := graticule() + for _, lon := range lons { + x, _ := project(0, lon) + c := colGrid + if lon == 0 { + c = colGridKey + } + drawVLine(img, x, c) + } + for _, lat := range lats { + _, y := project(lat, 0) + c := colGrid + if lat == 0 { + c = colGridKey + } + drawHLine(img, y, c) + } + + pts := bubbles(sum) + maxHits := maxCityHits(pts) + // 从小到大画:大气泡后画才能盖住小气泡,否则重要的点被长尾挡住。 + for i := len(pts) - 1; i >= 0; i-- { + p := pts[i] + x, y := project(p.Lat, p.Lon) + r := bubbleRadius(p.Hits, maxHits) + fillCircle(img, x, y, r, colBubble, 0.35) + strokeCircle(img, x, y, r, colRim, 0.8) + } + + // 刻度先画:气泡后画会盖住刻度,保证气泡在视觉上优先。 + for _, lon := range tickLons { + x, _ := project(0, lon) + s := lonLabel(lon) + tx := x - basicfontWidth(s)/2 + if tx < 2 { + tx = 2 + } + if tx+basicfontWidth(s) > mapWidth-2 { + tx = mapWidth - 2 - basicfontWidth(s) + } + drawText(img, tx, mapHeight-15, s, colGridKey, false) + } + for _, lat := range tickLats { + _, y := project(lat, 0) + drawText(img, 3, y-7, latLabel(lat), colGridKey, false) + } + + // 前 8 个城市标国家码 + 次数,重叠的跳过(理由见 labelPacker)。 + packer := newLabelPacker(mapWidth, mapHeight) + for i, p := range pts { + if i >= 8 { + break + } + x, y := project(p.Lat, p.Lon) + r := bubbleRadius(p.Hits, maxHits) + label := fmt.Sprintf("%s %d", p.CountryCode, p.Hits) + tx, ty, ok := packer.place(x, y, r, basicfontWidth(label), 13) + if !ok { + continue + } + drawText(img, tx, ty, label, colText, true) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// basicfontWidth 7x13 位图字体的定宽推进值:每个字符正好 7px。 +func basicfontWidth(s string) float64 { return float64(len(s)) * 7 } + +// drawText 用 7x13 位图字体写一行 ASCII 文字,(x, top) 是文本框左上角。 +// +// outline=true 时先描一圈白,避免文字压在气泡或网格上糊成一片。 +func drawText(img *image.RGBA, x, top float64, s string, c color.RGBA, outline bool) { + dot := fixed.P(int(x), int(top)+11) + if outline { + for _, off := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { + h := &font.Drawer{ + Dst: img, Src: &image.Uniform{color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}}, + Face: basicfont.Face7x13, + Dot: fixed.P(int(x)+off[0], int(top)+11+off[1]), + } + h.DrawString(s) + } + } + d := &font.Drawer{Dst: img, Src: &image.Uniform{c}, Face: basicfont.Face7x13, Dot: dot} + d.DrawString(s) +} + +// fillCircle 画一个带 1px 抗锯齿边缘的实心圆(alpha 混合)。 +func fillCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) { + drawCircleRing(img, cx, cy, 0, r, c, alpha) +} + +// strokeCircle 画一个 1px 宽的圆环。 +func strokeCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) { + drawCircleRing(img, cx, cy, r-0.5, r+0.5, c, alpha) +} + +// drawCircleRing 填充内半径 inner 到外半径 outer 之间的环(inner=0 即实心圆)。 +func drawCircleRing(img *image.RGBA, cx, cy, inner, outer float64, c color.RGBA, alpha float64) { + x0 := int(math.Floor(cx - outer - 1)) + x1 := int(math.Ceil(cx + outer + 1)) + y0 := int(math.Floor(cy - outer - 1)) + y1 := int(math.Ceil(cy + outer + 1)) + if x0 < 0 { + x0 = 0 + } + if y0 < 0 { + y0 = 0 + } + if x1 > img.Bounds().Dx() { + x1 = img.Bounds().Dx() + } + if y1 > img.Bounds().Dy() { + y1 = img.Bounds().Dy() + } + + for y := y0; y < y1; y++ { + for x := x0; x < x1; x++ { + dx := float64(x) + 0.5 - cx + dy := float64(y) + 0.5 - cy + d := math.Hypot(dx, dy) + // 与圆环相交的覆盖率:边缘 1px 内线性过渡,起到抗锯齿作用。 + cov := math.Min(outer-d, d-inner) + 0.5 + if cov <= 0 { + continue + } + if cov > 1 { + cov = 1 + } + blendPixel(img, x, y, c, alpha*cov) + } + } +} + +// drawVLine / drawHLine 画 1px 网格线(半透明,不抢气泡的视觉权重)。 +func drawVLine(img *image.RGBA, x float64, c color.RGBA) { + xi := int(math.Round(x)) + if xi < 0 || xi >= img.Bounds().Dx() { + return + } + for y := 0; y < img.Bounds().Dy(); y++ { + blendPixel(img, xi, y, c, 0.9) + } +} + +func drawHLine(img *image.RGBA, y float64, c color.RGBA) { + yi := int(math.Round(y)) + if yi < 0 || yi >= img.Bounds().Dy() { + return + } + for x := 0; x < img.Bounds().Dx(); x++ { + blendPixel(img, x, yi, c, 0.9) + } +} + +// blendPixel 把颜色以 alpha 混合到目标像素上。 +func blendPixel(img *image.RGBA, x, y int, c color.RGBA, alpha float64) { + if alpha <= 0 { + return + } + if alpha > 1 { + alpha = 1 + } + i := img.PixOffset(x, y) + dst := img.Pix[i : i+4 : i+4] + img.Pix[i+0] = uint8(float64(c.R)*alpha + float64(dst[0])*(1-alpha)) + img.Pix[i+1] = uint8(float64(c.G)*alpha + float64(dst[1])*(1-alpha)) + img.Pix[i+2] = uint8(float64(c.B)*alpha + float64(dst[2])*(1-alpha)) + img.Pix[i+3] = 0xFF +} diff --git a/internal/security/report/mapdraw_test.go b/internal/security/report/mapdraw_test.go new file mode 100644 index 0000000..f333534 --- /dev/null +++ b/internal/security/report/mapdraw_test.go @@ -0,0 +1,212 @@ +// 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 ( + "bytes" + "image/png" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/tamakyi/TamaBox/internal/db" + "github.com/tamakyi/TamaBox/internal/security/geoip" +) + +// sampleSummary 构造一份含两国三城的汇总,供渲染测试复用。 +func sampleSummary() *Summary { + loc := time.Local + start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) + period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} + return Build(Input{ + Period: period, + Sources: []*db.HoneypotSourceAgg{ + {IP: "203.0.113.1", Hits: 60, Banned: 60, Creds: 5, Provider: "微软 Azure(云厂商)"}, + {IP: "203.0.113.2", Hits: 30, Banned: 30, Creds: 2, Provider: "未识别(普通网络)"}, + {IP: "198.51.100.9", Hits: 10, Banned: 10}, + }, + Paths: []*db.HoneypotPathStat{{Path: "/wp-login.php", Count: 70}}, + Geo: map[string]geoip.Geo{ + "203.0.113.1": {Country: "美国", CountryCode: "US", Region: "Virginia", City: "Ashburn", Lat: 39.04, Lon: -77.49, HasCoords: true, ISP: "Amazon", ASN: "AS14618"}, + "203.0.113.2": {Country: "中国", CountryCode: "CN", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true, ISP: "联通"}, + "198.51.100.9": {Country: "德国", CountryCode: "DE", Region: "Berlin", City: "Berlin", Lat: 52.5, Lon: 13.4, HasCoords: true}, + }, + Now: start, + }) +} + +// TestRenderPNG 覆盖位图地图:必须是合法 PNG、尺寸固定、非空白。 +// +// 空白图是这里最容易出的问题:坐标算错时所有气泡会画到画布外, +// 得到的仍是一张「合法但全白」的 PNG,肉眼要打开附件才发现。 +func TestRenderPNG(t *testing.T) { + sum := sampleSummary() + data, err := RenderPNG(sum) + require.NoError(t, err) + require.True(t, len(data) > 1024, "PNG 太小,怀疑是空白图") + + img, err := png.Decode(bytes.NewReader(data)) + require.NoError(t, err) + require.Equal(t, mapWidth, img.Bounds().Dx()) + require.Equal(t, mapHeight, img.Bounds().Dy()) + + // 统计与背景色不同的像素:气泡、网格、文字都会贡献非背景像素。 + bg := img.At(1, 1) + diff := 0 + for y := 0; y < mapHeight; y += 4 { + for x := 0; x < mapWidth; x += 4 { + r1, g1, b1, _ := img.At(x, y).RGBA() + r2, g2, b2, _ := bg.RGBA() + if r1 != r2 || g1 != g2 || b1 != b2 { + diff++ + } + } + } + require.Greater(t, diff, 500, "画面上几乎没有非背景像素,地图可能画空了") +} + +// TestRenderPNGDropsBadCoords 覆盖越界坐标:不能画到 0,0 去。 +func TestRenderPNGDropsBadCoords(t *testing.T) { + loc := time.Local + start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) + period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} + + sum := Build(Input{ + Period: period, + Sources: []*db.HoneypotSourceAgg{{IP: "203.0.113.1", Hits: 5}}, + Geo: map[string]geoip.Geo{ + // HasCoords=true 但坐标是 (0,0):IP 库对「只知道国家」的常见输出。 + "203.0.113.1": {Country: "未知海域", CountryCode: "XX", Lat: 0, Lon: 0, HasCoords: true}, + }, + Now: start, + }) + require.Empty(t, bubbles(sum), "(0,0) 必须被丢弃,否则会画到几内亚湾") +} + +// TestRenderSVG 覆盖 SVG:每个有坐标的城市都应该有一个 circle。 +func TestRenderSVG(t *testing.T) { + sum := sampleSummary() + svg := RenderSVG(sum) + require.Contains(t, svg, "", "空报告不该渲染数据表") +} + +// TestBubbleRadiusMonotonic 覆盖气泡缩放:命中多的必须更大,且线性增长不会 +// 把长尾压得看不见(sqrt 缩放)。 +func TestBubbleRadiusMonotonic(t *testing.T) { + small := bubbleRadius(1, 1000) + mid := bubbleRadius(100, 1000) + big := bubbleRadius(1000, 1000) + require.Less(t, small, mid) + require.Less(t, mid, big) + // 1/1000 的命中也要有可辨识的尺寸 + require.Greater(t, small, 3.0) + require.Less(t, big, 21.0) +} + +// TestLabelPackerNoOverlap 是「AU 305」那个 bug 的回归测试。 +// +// 同一个都市圈的不同城市坐标只差 0.01°,两行标签会精确重叠,叠出一个 +// 看起来像真实数字的假字符串(实测把 "AU 55" 与 "AU 30" 叠成了 "AU 305")。 +// 读图的人不会怀疑「305 是从哪来的」,只会把它当结论 —— 所以宁可少标。 +func TestLabelPackerNoOverlap(t *testing.T) { + p := newLabelPacker(mapWidth, mapHeight) + + // 三个几乎重合的气泡:第一个标在右侧、第二个自动换到左侧(都清晰可读), + // 第三个两侧都被占就只能放弃 —— 关键是它们不能叠在一起。 + for i := 0; i < 3; i++ { + x, y := project(-27.48+float64(i)*0.01, 153.02+float64(i)*0.01) + p.place(x, y, 10, basicfontWidth("AU 55"), 13) + } + require.Len(t, p.placed, 2, "三个重合的气泡最多只能放下两个不重叠的标签") + requireNoOverlap(t, p.placed) + + // 远离的气泡必须都能标上 + p2 := newLabelPacker(mapWidth, mapHeight) + require.True(t, mustPlace(t, p2, 100, 100)) + require.True(t, mustPlace(t, p2, 400, 300)) + require.Len(t, p2.placed, 2) + requireNoOverlap(t, p2.placed) +} + +// requireNoOverlap 断言任意两个标签框都不相交 —— 这是标签层的唯一不变量。 +func requireNoOverlap(t *testing.T, boxes []labelBox) { + t.Helper() + for i := 0; i < len(boxes); i++ { + for j := i + 1; j < len(boxes); j++ { + a, b := boxes[i], boxes[j] + overlap := a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 + require.False(t, overlap, "标签 %d 与 %d 重叠:%v / %v", i, j, a, b) + } + } +} + +// TestLabelPackerClampsToCanvas 靠右边缘的气泡要把标签放到左边,而不是画出画布。 +func TestLabelPackerClampsToCanvas(t *testing.T) { + p := newLabelPacker(mapWidth, mapHeight) + w := basicfontWidth("AU 55") + // 气泡贴着右边缘:右侧放不下,应自动改到左侧 + x, _, ok := p.place(mapWidth-12, 240, 10, w, 13) + require.True(t, ok) + require.LessOrEqual(t, x+w, float64(mapWidth-2)) + + // 两条边都放不下(画布外)应放弃 + p2 := newLabelPacker(20, 20) + _, _, ok = p2.place(10, 10, 40, w, 13) + require.False(t, ok) +} + +func mustPlace(t *testing.T, p *labelPacker, bx, by float64) bool { + t.Helper() + _, _, ok := p.place(bx, by, 10, basicfontWidth("US 90"), 13) + return ok +} diff --git a/internal/security/report/period.go b/internal/security/report/period.go new file mode 100644 index 0000000..e7ff222 --- /dev/null +++ b/internal/security/report/period.go @@ -0,0 +1,115 @@ +// 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 生成周期性安全状况汇总(周报 / 月报)。 +// +// 它回答的问题与蜜罐告警不同:告警是「刚刚有人在你门口试钥匙」,报告是 +// 「这一周一共有多少人在试、从哪些国家来、哪几个网段最执着、我们在拦什么」。 +// +// 产物分三份,各有各的用途: +// - 邮件正文里的 PNG 气泡地图:邮件客户端不执行 JS、多数还会剥离内联 SVG, +// 只有位图能保证「打开就看得到」; +// - 附件 HTML:完整报告(排行表 + 内联 SVG 地图),离线可看; +// - 附件 HTML 里的腾讯地图:配了 key 才有,可缩放、可点开看明细。 +// +// ⚠️ 地图的合规边界:本包自己画的地图**只画经纬网与气泡,不画任何国界**。 +// 国界、台湾、南海诸岛的绘制必须符合国家标准,自绘底图极容易画错, +// 所以自绘图一律不含政治边界;需要真实底图时用腾讯地图(底图由持牌厂商提供)。 +package report + +import ( + "fmt" + "time" +) + +// 周期类型。 +const ( + KindWeek = "week" + KindMonth = "month" +) + +// Period 一个报告周期,时间窗口为 [Start, End)。 +// +// 左闭右开是刻意的:相邻两期既不能重叠(同一条记录算两次),也不能漏掉 +// 恰好落在边界整点上的记录。 +type Period struct { + Kind string + Key string + Start time.Time + End time.Time +} + +// Label 返回人类可读的窗口描述,如 "2026-10-19 ~ 2026-10-25"。 +func (p Period) Label() string { + // End 是开区间端点,展示时退一秒落回周期内的最后一天,避免出现 + // 「10-19 ~ 10-26」这种让人以为多算了一天的区间。 + last := p.End.Add(-time.Second) + if p.Kind == KindMonth { + return p.Start.Format("2006-01") + } + return fmt.Sprintf("%s ~ %s", p.Start.Format("2006-01-02"), last.Format("2006-01-02")) +} + +// Title 返回报告标题用的中文描述。 +func (p Period) Title() string { + if p.Kind == KindMonth { + return p.Start.Format("2006 年 1 月") + "安全状况月报" + } + _, week := p.End.Add(-time.Second).ISOWeek() + return fmt.Sprintf("%s 第 %d 周安全状况周报", p.Start.Format("2006 年 1 月"), week) +} + +// Filename 返回报告文件名(不含扩展名)。 +func (p Period) Filename() string { + if p.Kind == KindMonth { + return "security-month-" + p.Start.Format("2006-01") + } + return "security-week-" + p.Start.Format("2006-01-02") +} + +// startOfWeek 返回 t 所在周的周一 00:00(本地时区)。 +func startOfWeek(t time.Time) time.Time { + wd := int(t.Weekday()) // Sunday=0 … Saturday=6 + if wd == 0 { + wd = 7 + } + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, t.Location()).AddDate(0, 0, -(wd - 1)) +} + +// startOfMonth 返回 t 所在月的 1 日 00:00。 +func startOfMonth(t time.Time) time.Time { + y, m, _ := t.Date() + return time.Date(y, m, 1, 0, 0, 0, 0, t.Location()) +} + +// PreviousWeek 返回 now 之前的**完整**一周。 +// +// 口径是「最近一个已结束的自然周(周一起算)」:在周一凌晨跑,拿到的是上周; +// 在周三跑(补发),拿到的仍然是上周而不是「本周至今」—— +// 报告必须覆盖完整周期,否则两次运行之间数字对不上。 +func PreviousWeek(now time.Time) Period { + end := startOfWeek(now) + start := end.AddDate(0, 0, -7) + // 用周期内最后一天算 ISO 周号:直接用 end 会被算成下一周。 + y, w := end.Add(-time.Second).ISOWeek() + return Period{ + Kind: KindWeek, + Key: fmt.Sprintf("week:%04d-W%02d", y, w), + Start: start, + End: end, + } +} + +// PreviousMonth 返回 now 之前的完整一个自然月。 +func PreviousMonth(now time.Time) Period { + end := startOfMonth(now) + start := end.AddDate(0, -1, 0) + return Period{ + Kind: KindMonth, + Key: "month:" + start.Format("2006-01"), + Start: start, + End: end, + } +} diff --git a/internal/security/report/period_test.go b/internal/security/report/period_test.go new file mode 100644 index 0000000..7eafb53 --- /dev/null +++ b/internal/security/report/period_test.go @@ -0,0 +1,101 @@ +// 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 ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestPreviousWeek 覆盖周报窗口:[周一 00:00, 下周一 00:00),且与「现在是周几」无关。 +func TestPreviousWeek(t *testing.T) { + loc := time.Local + + cases := []struct { + name string + now time.Time + wantStart time.Time + wantEnd time.Time + }{ + { + name: "周一凌晨(报告应在此时发出,覆盖上周)", + now: time.Date(2026, 10, 26, 1, 0, 0, 0, loc), // 周一 + wantStart: time.Date(2026, 10, 19, 0, 0, 0, 0, loc), + wantEnd: time.Date(2026, 10, 26, 0, 0, 0, 0, loc), + }, + { + name: "周三补发:仍然覆盖「上周」而不是本周至今", + now: time.Date(2026, 10, 28, 15, 30, 0, 0, loc), // 周三 + wantStart: time.Date(2026, 10, 19, 0, 0, 0, 0, loc), + wantEnd: time.Date(2026, 10, 26, 0, 0, 0, 0, loc), + }, + { + name: "周日晚间:还没到本周结束,仍覆盖上周", + now: time.Date(2026, 10, 25, 23, 59, 0, 0, loc), // 周日 + wantStart: time.Date(2026, 10, 12, 0, 0, 0, 0, loc), + wantEnd: time.Date(2026, 10, 19, 0, 0, 0, 0, loc), + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := PreviousWeek(c.now) + require.Equal(t, c.wantStart, p.Start) + require.Equal(t, c.wantEnd, p.End) + // 窗口必须正好 7 天,且左闭右开 + require.Equal(t, 7*24*time.Hour, p.End.Sub(p.Start)) + }) + } +} + +// TestPreviousWeekKey 覆盖 ISO 周号,特别是跨年那一周。 +func TestPreviousWeekKey(t *testing.T) { + loc := time.Local + + // 2027-01-01 是周五,属于 ISO 2026 年第 53 周; + // 2026-12-28(周一)~ 2027-01-03(周日)正是这一周。 + // 若用 end 去算周号会得到 2027-W01,导致这一周的台账键错位。 + p := PreviousWeek(time.Date(2027, 1, 4, 9, 0, 0, 0, loc)) // 周一 + require.Equal(t, "week:2026-W53", p.Key) + require.Equal(t, time.Date(2026, 12, 28, 0, 0, 0, 0, loc), p.Start) + + // 跨年另一侧:2026-01-01 所在周是 2026-W01,前一周属于 2025-W52。 + p2 := PreviousWeek(time.Date(2026, 1, 5, 9, 0, 0, 0, loc)) + require.Equal(t, "week:2026-W01", p2.Key) +} + +// TestPreviousMonth 覆盖月报窗口,含 31 天/30 天/2 月。 +func TestPreviousMonth(t *testing.T) { + loc := time.Local + + p := PreviousMonth(time.Date(2026, 11, 3, 10, 0, 0, 0, loc)) + require.Equal(t, "month:2026-10", p.Key) + require.Equal(t, time.Date(2026, 10, 1, 0, 0, 0, 0, loc), p.Start) + require.Equal(t, time.Date(2026, 11, 1, 0, 0, 0, 0, loc), p.End) + + // 闰年 2 月必须有 29 天,且不能因为「减一个月」跑到 3 月去。 + p2 := PreviousMonth(time.Date(2024, 3, 1, 0, 30, 0, 0, loc)) + require.Equal(t, "month:2024-02", p2.Key) + require.Equal(t, time.Date(2024, 2, 1, 0, 0, 0, 0, loc), p2.Start) + require.Equal(t, 29*24*time.Hour, p2.End.Sub(p2.Start)) + + // 5 月 31 日往前一个月不能变成「4 月 31 日」这种不存在的日期: + // 窗口是 [4-01, 5-01),不需要做任何日期加减。 + p3 := PreviousMonth(time.Date(2026, 5, 31, 12, 0, 0, 0, loc)) + require.Equal(t, time.Date(2026, 4, 1, 0, 0, 0, 0, loc), p3.Start) + require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, loc), p3.End) +} + +// TestPeriodLabel 覆盖展示用的窗口描述:右端点要退一天,别让人以为多算了一天。 +func TestPeriodLabel(t *testing.T) { + loc := time.Local + p := PreviousWeek(time.Date(2026, 10, 26, 1, 0, 0, 0, loc)) + require.Equal(t, "2026-10-19 ~ 2026-10-25", p.Label()) + + m := PreviousMonth(time.Date(2026, 11, 3, 10, 0, 0, 0, loc)) + require.Equal(t, "2026-10", m.Label()) + require.Equal(t, "security-month-2026-10", m.Filename()) +} diff --git a/templates/mail/security-report.html b/templates/mail/security-report.html new file mode 100644 index 0000000..6e57c97 --- /dev/null +++ b/templates/mail/security-report.html @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+
+
+
+ {{.Title}} +
+
+ 统计窗口 {{.Period}} · 生成于 {{.Generated}} +
+
+ +
+ {{.SummaryHTML}} +
+ + {{if .MapCID}} +
+
+ 来源分布(气泡面积与命中次数成正比) +
+ 蜜罐命中来源世界地图 +
+ 图为 IP 库给出的城市级定位,精度到城市,不代表对方真实位置。图中只画经纬网与气泡,不含任何国界。 +
+
+ {{end}} + +
+ {{.CountriesHTML}} +
+ +
+ {{.Notes}} +
+ +
+ 完整报告(含来源明细、被探测路径、每日趋势)见附件 HTML,用浏览器打开即可。
+ 本邮件由 TamaBox 蜜罐自动生成,收件人应仅为站点运维本人。 +
+
+
+
+ + diff --git a/templates/render_test.go b/templates/render_test.go index cd98d3d..8273989 100644 --- a/templates/render_test.go +++ b/templates/render_test.go @@ -115,6 +115,8 @@ type mockLoggedUser struct { Bloglink string ChatMaxMessages int DailyMessageLimit int + // Telegram 通知的会话 ID(profile.html 的通知面板会回显)。 + TelegramChatID string } // profileBaseData 构造 /user/profile 渲染所需的基础数据。