feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。
新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
(一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
每次运行精修到不同 IP,配额花在长尾上。
报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。
存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)
配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。
顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
This commit is contained in:
@@ -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://<acct>.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>.<ext>`),还是维持现状。这是存储层正确性问题,未动。
|
||||
|
||||
## 存储层 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/<UnixNano>.<原扩展名>`**(无前缀、无 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/<UnixNano><ext>`(唯一 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/[email protected]/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`。
|
||||
|
||||
@@ -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 个渲染测试失败
|
||||
@@ -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 之类会被拒绝,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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(`<table width="100%" border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">`)
|
||||
for i := 0; i < len(kpis); i += 2 {
|
||||
b.WriteString(`<tr>`)
|
||||
for j := i; j < i+2 && j < len(kpis); j++ {
|
||||
b.WriteString(`<td width="50%" style="padding:6px;">`)
|
||||
b.WriteString(`<div style="border:1px solid #dadce0;border-radius:8px;padding:12px 14px;">`)
|
||||
fmt.Fprintf(&b, `<div style="font-size:12px;color:rgba(0,0,0,0.54);">%s</div>`, kpis[j][0])
|
||||
fmt.Fprintf(&b, `<div style="font-size:20px;font-weight:600;color:#202124;margin-top:2px;">%s</div>`, kpis[j][1])
|
||||
b.WriteString(`</div></td>`)
|
||||
}
|
||||
if i+1 >= len(kpis) {
|
||||
b.WriteString(`<td width="50%"></td>`)
|
||||
}
|
||||
b.WriteString(`</tr>`)
|
||||
}
|
||||
b.WriteString(`</table>`)
|
||||
|
||||
if sum.PrevHits > 0 {
|
||||
delta := float64(sum.TotalHits-sum.PrevHits) / float64(sum.PrevHits) * 100
|
||||
arrow := "上升"
|
||||
if delta < 0 {
|
||||
arrow = "下降"
|
||||
}
|
||||
fmt.Fprintf(&b, `<div style="font-size:13px;color:rgba(0,0,0,0.6);margin-top:10px;">较上一周期%s %.0f%%(上期 %d 次)。</div>`,
|
||||
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(`<div style="font-size:13px;color:rgba(0,0,0,0.54);margin-bottom:8px;">来源国家 / 地区(前 10)</div>`)
|
||||
b.WriteString(`<table width="100%" border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:13px;">`)
|
||||
b.WriteString(`<tr>`)
|
||||
for _, h := range []string{"国家/地区", "命中", "占比"} {
|
||||
align := "left"
|
||||
if h != "国家/地区" {
|
||||
align = "right"
|
||||
}
|
||||
fmt.Fprintf(&b, `<th align="%s" style="text-align:right;padding:6px 8px;border-bottom:1px solid #dadce0;color:rgba(0,0,0,0.54);font-weight:600;text-align:%s;">%s</th>`,
|
||||
align, align, h)
|
||||
}
|
||||
b.WriteString(`</tr>`)
|
||||
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, `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f3f4;color:#202124;">%s</td>`,
|
||||
html.EscapeString(name))
|
||||
fmt.Fprintf(&b, `<td align="right" style="padding:6px 8px;border-bottom:1px solid #f1f3f4;text-align:right;color:#202124;">%d</td>`, c.Hits)
|
||||
fmt.Fprintf(&b, `<td align="right" style="padding:6px 8px;border-bottom:1px solid #f1f3f4;text-align:right;color:rgba(0,0,0,0.6);">%s</td></tr>`,
|
||||
html.EscapeString(pctOf(c.Hits, sum.TotalHits)))
|
||||
}
|
||||
b.WriteString(`</table>`)
|
||||
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<br/>`, 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)
|
||||
}
|
||||
@@ -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), "窗口未结束时不能发不完整的报告")
|
||||
}
|
||||
+14
-1
@@ -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(含匿名访客)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 归一化配置键名。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -45,6 +45,10 @@ var AllTables = []interface{}{
|
||||
&UserRestriction{}, &ContentReport{},
|
||||
// 蜜罐命中记录(审计证据,只追加不软删)
|
||||
&HoneypotHit{},
|
||||
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
|
||||
&IPGeoRecord{},
|
||||
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
|
||||
&SecurityReportLog{},
|
||||
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
|
||||
&BanRecord{},
|
||||
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "[email protected]", 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: "[email protected]", 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 里写 <img src="cid:xxx">,邮件客户端就会用
|
||||
// 附件里的这张图渲染。这是让「地图不依赖任何外链、不依赖 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 传:里面有多行 <br/> 结构。
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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(`<div class="kpi">命中 <strong>554</strong> 次</div>`),
|
||||
CountriesHTML: template.HTML(`<table><tr><td>美国</td><td>160</td></tr></table>`),
|
||||
Notes: template.HTML(`口径一<br/>口径二`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// HTML 片段原样出现
|
||||
require.Contains(t, out, "<strong>554</strong>")
|
||||
require.Contains(t, out, "<td>美国</td>")
|
||||
require.Contains(t, out, "口径一<br/>口径二")
|
||||
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, "{{")
|
||||
}
|
||||
@@ -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 验证按路径分发到不同后台的伪装。
|
||||
//
|
||||
// 扫描器先按路径判断「这是什么后台」,再拿对应字段名去填表;
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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(""))
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 "未知"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">`)
|
||||
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1">`)
|
||||
fmt.Fprintf(&b, `<title>%s</title>`, html.EscapeString(title))
|
||||
b.WriteString(pageCSS)
|
||||
b.WriteString(`</head><body><div class="wrap">`)
|
||||
|
||||
// 头部
|
||||
fmt.Fprintf(&b, `<header><h1>%s</h1><p class="sub">统计窗口 <b>%s</b> · 生成于 %s</p></header>`,
|
||||
html.EscapeString(title), html.EscapeString(sum.Period.Label()),
|
||||
sum.GeneratedAt.Format("2006-01-02 15:04"))
|
||||
|
||||
if sum.Empty {
|
||||
b.WriteString(`<div class="empty">本期没有捕获到任何蜜罐命中 —— 这本身是个好消息,但也请确认蜜罐仍然开启(<code>TamaBox honeypot paths</code>)。</div>`)
|
||||
b.WriteString(`</div></body></html>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// KPI
|
||||
b.WriteString(`<section class="kpis">`)
|
||||
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(`</section>`)
|
||||
|
||||
// 地图
|
||||
b.WriteString(`<section><h2>来源分布</h2>`)
|
||||
b.WriteString(`<p class="note">气泡面积与命中次数成正比。地图上的点是 IP 库给出的城市级定位,精度到城市,不代表对方的真实街道位置。</p>`)
|
||||
b.WriteString(`<div class="map-frame">`)
|
||||
b.WriteString(RenderSVG(sum))
|
||||
b.WriteString(`</div>`)
|
||||
b.WriteString(`<p class="note">⚠️ 本图只画经纬网与气泡,<b>不含任何国界</b>。国界、台湾与南海诸岛的正确绘制须符合国家标准,自绘底图容易出错,因此这里不做底图;需要真实底图请看下方的腾讯地图版本。</p>`)
|
||||
b.WriteString(`</section>`)
|
||||
|
||||
// 腾讯可交互地图
|
||||
b.WriteString(`<section><h2>可交互地图</h2>`)
|
||||
b.WriteString(renderTencentMap(sum, opts.TencentMapKey))
|
||||
b.WriteString(`</section>`)
|
||||
|
||||
// 国家排行
|
||||
b.WriteString(`<section><h2>国家 / 地区排行</h2>`)
|
||||
b.WriteString(`<table><thead><tr><th>#</th><th>国家 / 地区</th><th class="num">命中</th><th class="num">占比</th><th class="num">独立来源</th></tr></thead><tbody>`)
|
||||
for i, c := range sum.Countries {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s <span class="muted">%s</span></td><td class="num">%d</td><td class="num">%s</td><td class="num">%d</td></tr>`,
|
||||
i+1, html.EscapeString(c.Name), html.EscapeString(c.Code), c.Hits, pctFloat(c.Share), c.Sources)
|
||||
}
|
||||
b.WriteString(`</tbody></table></section>`)
|
||||
|
||||
// 城市排行
|
||||
if len(sum.Cities) > 0 {
|
||||
b.WriteString(`<section><h2>城市排行(前 20)</h2>`)
|
||||
b.WriteString(`<table><thead><tr><th>#</th><th>城市</th><th>国家</th><th class="num">命中</th><th class="num">独立来源</th></tr></thead><tbody>`)
|
||||
for i, c := range sum.Cities {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
city := c.City
|
||||
if city == "" {
|
||||
city = c.Region
|
||||
}
|
||||
if city == "" {
|
||||
city = "(未知城市)"
|
||||
}
|
||||
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s</td><td>%s</td><td class="num">%d</td><td class="num">%d</td></tr>`,
|
||||
i+1, html.EscapeString(city), html.EscapeString(c.Country), c.Hits, c.Sources)
|
||||
}
|
||||
b.WriteString(`</tbody></table></section>`)
|
||||
}
|
||||
|
||||
// 来源明细
|
||||
b.WriteString(`<section><h2>来源明细(前 50)</h2>`)
|
||||
b.WriteString(`<table><thead><tr><th>IP</th><th>位置</th><th>运营商 / ASN</th><th>网络归属</th><th class="num">命中</th><th class="num">破解</th><th class="num">拦截</th></tr></thead><tbody>`)
|
||||
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, `<tr><td class="mono">%s</td><td>%s</td><td class="small">%s</td><td class="small">%s</td><td class="num">%d</td><td class="num">%d</td><td class="num">%d</td></tr>`,
|
||||
html.EscapeString(s.IP), html.EscapeString(s.Location()),
|
||||
html.EscapeString(isp), html.EscapeString(provider),
|
||||
s.Hits, s.Creds, s.Banned)
|
||||
}
|
||||
b.WriteString(`</tbody></table>`)
|
||||
if len(sum.Sources) >= 50 {
|
||||
fmt.Fprintf(&b, `<p class="note">共 %d 个独立来源,此处只列命中次数最高的 50 个。完整明细:<code>TamaBox honeypot stats --days %d</code></p>`,
|
||||
sum.UniqueSources, int(sum.Period.End.Sub(sum.Period.Start).Hours()/24))
|
||||
}
|
||||
b.WriteString(`</section>`)
|
||||
|
||||
// 路径
|
||||
if len(sum.Paths) > 0 {
|
||||
b.WriteString(`<section><h2>被探测路径</h2>`)
|
||||
b.WriteString(`<table><thead><tr><th>#</th><th>路径</th><th class="num">次数</th></tr></thead><tbody>`)
|
||||
for i, p := range sum.Paths {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&b, `<tr><td>%d</td><td class="mono">%s</td><td class="num">%d</td></tr>`,
|
||||
i+1, html.EscapeString(p.Path), p.Hits)
|
||||
}
|
||||
b.WriteString(`</tbody></table></section>`)
|
||||
}
|
||||
|
||||
// 趋势
|
||||
if len(sum.Days) > 1 {
|
||||
b.WriteString(`<section><h2>每日趋势</h2>`)
|
||||
b.WriteString(`<table class="trend"><tbody>`)
|
||||
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, `<tr><td class="day">%s</td><td class="bar"><span style="width:%.2f%%"></span></td><td class="num">%d</td></tr>`,
|
||||
d.Day.Format("01-02"), barW, d.Hits)
|
||||
}
|
||||
b.WriteString(`</tbody></table></section>`)
|
||||
}
|
||||
|
||||
// 口径说明
|
||||
b.WriteString(`<section><h2>口径与说明</h2><ul class="meta">`)
|
||||
fmt.Fprintf(&b, `<li>地理定位数据源:%s。</li>`, html.EscapeString(sum.ResolverDesc))
|
||||
fmt.Fprintf(&b, `<li>未能定位的命中:<b>%d</b> 次(占 %s)。这些 IP 在国内外的 IP 库里都没有城市级记录,通常是被回收中的地址段或运营商级 NAT。</li>`,
|
||||
sum.UnlocatedHits, pct(sum.UnlocatedHits, sum.TotalHits))
|
||||
b.WriteString(`<li>「破解」指对该次请求提交了账号密码字段;纯路径探测不计入。捕获到的账号密码本身不写进本报告,存放在服务器的取证目录(<code>TamaBox honeypot hits</code> 查看)。</li>`)
|
||||
b.WriteString(`<li>「网络归属」来自离线指纹(cdncheck),判定的是云厂商 / CDN / WAF,<b>不是</b>地理位置。显示「未识别(普通网络)」不代表没定位到,两者是不同的维度。</li>`)
|
||||
b.WriteString(`<li>独立来源按封禁口径聚合:IPv4 精确到地址,IPv6 折叠到 /64 —— 攻击者常在一个 /64 内轮换地址,按精确地址会把一台机器算成几十个。</li>`)
|
||||
b.WriteString(`</ul></section>`)
|
||||
|
||||
fmt.Fprintf(&b, `<footer>由 TamaBox 蜜罐自动生成 · %s</footer>`, sum.GeneratedAt.Format("2006-01-02 15:04:05"))
|
||||
b.WriteString(`</div></body></html>`)
|
||||
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(`<div class="hint">未配置腾讯地图 key,本节跳过。<br>如需可缩放的真实底图,%s。</div>`,
|
||||
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(`<div id="tmap" class="tmap"></div>
|
||||
<div id="tmap-status" class="note"></div>
|
||||
<script src="https://map.qq.com/api/gljs?v=1.exp&key=%s"></script>
|
||||
<script>
|
||||
(function () {
|
||||
if (typeof TMap === 'undefined') {
|
||||
document.getElementById('tmap-status').textContent = '腾讯地图 SDK 未能加载(可能是离线打开或 key 无效),请参考上方离线地图。';
|
||||
return;
|
||||
}
|
||||
var center = new TMap.LatLng(20, 10);
|
||||
var map = new TMap.Map('tmap', { center: center, zoom: 3, viewMode: '2D' });
|
||||
var styles = {};
|
||||
var sizes = [[8,8],[12,12],[18,18],[26,26],[34,34]];
|
||||
for (var i = 0; i < sizes.length; i++) {
|
||||
styles['s' + i] = new TMap.MarkerStyle({
|
||||
width: sizes[i][0], height: sizes[i][1],
|
||||
anchor: { x: sizes[i][0]/2, y: sizes[i][1]/2 },
|
||||
color: '#EF4444'
|
||||
});
|
||||
}
|
||||
new TMap.MultiMarker({ map: map, styles: styles, geometries: %s });
|
||||
document.getElementById('tmap-status').textContent = '共 %d 个城市级定位点,气泡越大命中越多。点击标记可见城市与次数。';
|
||||
})();
|
||||
</script>`,
|
||||
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 转义(防 </script> 逃逸),再转义引号与反斜杠。
|
||||
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(`<div class="kpi">`)
|
||||
fmt.Fprintf(b, `<div class="kpi-label">%s</div><div class="kpi-value">%s</div>`, html.EscapeString(label), html.EscapeString(value))
|
||||
if note != "" {
|
||||
fmt.Fprintf(b, `<div class="kpi-note">%s</div>`, html.EscapeString(note))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
|
||||
// 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 = `<style>
|
||||
:root{--fg:#0f172a;--muted:#64748b;--line:#e2e8f0;--bg:#ffffff;--accent:#dc2626;--tint:#f8fafc}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--tint);color:var(--fg);font:14px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:32px 20px 60px}
|
||||
header h1{margin:0 0 4px;font-size:24px;letter-spacing:-.01em}
|
||||
header .sub{margin:0;color:var(--muted);font-size:13px}
|
||||
section{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:20px;margin-top:18px}
|
||||
h2{font-size:15px;margin:0 0 12px;font-weight:600}
|
||||
.note{color:var(--muted);font-size:12.5px;margin:8px 0 0}
|
||||
.muted{color:var(--muted)}
|
||||
.small{font-size:12.5px;color:var(--muted)}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
|
||||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:18px}
|
||||
.kpi{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
|
||||
.kpi-label{font-size:12px;color:var(--muted)}
|
||||
.kpi-value{font-size:26px;font-weight:650;letter-spacing:-.02em;margin:2px 0}
|
||||
.kpi-note{font-size:12px;color:var(--muted)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{font-weight:600;color:var(--muted);font-size:12px;background:var(--tint)}
|
||||
.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.map-frame{border:1px solid var(--line);border-radius:8px;overflow:hidden;line-height:0}
|
||||
.tmap{height:420px;border:1px solid var(--line);border-radius:8px}
|
||||
.hint{background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:12px 14px;color:#9a3412;font-size:13px}
|
||||
.empty{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:28px;text-align:center;color:var(--muted)}
|
||||
table.trend td{border:0;padding:2px 8px}
|
||||
table.trend .day{color:var(--muted);font-size:12px;white-space:nowrap;width:60px}
|
||||
table.trend .bar{padding:0 8px}
|
||||
table.trend .bar span{display:block;height:12px;background:linear-gradient(90deg,#f87171,#dc2626);border-radius:2px;min-width:1px}
|
||||
table.trend .num{width:70px}
|
||||
ul.meta{margin:0;padding-left:18px;color:#334155;font-size:13px}
|
||||
ul.meta li{margin-bottom:6px}
|
||||
footer{margin-top:26px;text-align:center;color:var(--muted);font-size:12px}
|
||||
code{background:var(--tint);border:1px solid var(--line);border-radius:4px;padding:1px 5px;font-size:12px}
|
||||
@media print{body{background:#fff}section{break-inside:avoid;border-color:#ccc}}
|
||||
</style>`
|
||||
|
||||
// FormatTime 供模板层统一时间格式。
|
||||
func FormatTime(t time.Time) string { return t.Format("2006-01-02 15:04") }
|
||||
@@ -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, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" role="img" aria-label="蜜罐命中来源分布世界地图">`, mapWidth, mapHeight)
|
||||
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#F1F5F9"/>`, 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, `<line x1="%.1f" y1="0" x2="%.1f" y2="%d" stroke="%s" stroke-width="%s"/>`, 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, `<line x1="0" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="%s"/>`, 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, `<text x="%.1f" y="%d" font-size="9" fill="#94A3B8" text-anchor="middle" font-family="monospace">%s</text>`,
|
||||
x, mapHeight-4, lonLabel(lon))
|
||||
}
|
||||
for _, lat := range tickLats {
|
||||
_, y := project(lat, 0)
|
||||
fmt.Fprintf(&b, `<text x="3" y="%.1f" font-size="9" fill="#94A3B8" font-family="monospace">%s</text>`,
|
||||
y+3, latLabel(lat))
|
||||
}
|
||||
|
||||
// 气泡
|
||||
for _, p := range pts {
|
||||
x, y := project(p.Lat, p.Lon)
|
||||
r := bubbleRadius(p.Hits, maxHits)
|
||||
fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="%.1f" fill="#EF4444" fill-opacity="0.35" stroke="#B91C1C" stroke-opacity="0.8" stroke-width="1"><title>%s · %s:%d 次命中(%d 个来源)</title></circle>`,
|
||||
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, `<text x="%.1f" y="%.1f" font-size="11" fill="#334155" font-family="system-ui,-apple-system,Segoe UI,sans-serif">%s</text>`,
|
||||
tx, ty+10, html.EscapeString(label))
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
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
|
||||
}
|
||||
@@ -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, "<svg")
|
||||
// 三个城市 → 三个气泡
|
||||
require.Equal(t, 3, strings.Count(svg, "<circle"))
|
||||
require.Contains(t, svg, "北京")
|
||||
// 投影后的坐标要落在画布内(0..960 / 0..480),而不是 NaN 或负数。
|
||||
require.Contains(t, svg, `cx="790.4"`) // 北京 116.4E → x≈790
|
||||
// 不允许出现任何国界相关元素:自绘底图不画政治边界,避免画错。
|
||||
require.NotContains(t, svg, "<path")
|
||||
}
|
||||
|
||||
// TestRenderHTMLNoKey 覆盖「未配腾讯 key」:给出申请指引,且不能偷偷塞一个 key。
|
||||
func TestRenderHTMLNoKey(t *testing.T) {
|
||||
sum := sampleSummary()
|
||||
html := RenderHTML(sum, HTMLOptions{})
|
||||
require.Contains(t, html, "未配置腾讯地图 key")
|
||||
require.Contains(t, html, "lbs.qq.com")
|
||||
require.NotContains(t, html, "map.qq.com/api/gljs", "没有 key 时不应加载腾讯 SDK")
|
||||
// 离线 SVG 必须始终存在
|
||||
require.Contains(t, html, "<svg")
|
||||
// 关键数字要出现在报告里
|
||||
require.Contains(t, html, "100") // 总命中 60+30+10
|
||||
}
|
||||
|
||||
// TestRenderHTMLWithKey 覆盖「配了 key」:加载腾讯 SDK 并带正确 key。
|
||||
func TestRenderHTMLWithKey(t *testing.T) {
|
||||
sum := sampleSummary()
|
||||
html := RenderHTML(sum, HTMLOptions{TencentMapKey: "TEST-KEY-123"})
|
||||
require.Contains(t, html, "map.qq.com/api/gljs")
|
||||
require.Contains(t, html, "TEST-KEY-123")
|
||||
// 合规:不能出现被禁止的境外底图(大小写无关地查一遍)。
|
||||
lower := strings.ToLower(html)
|
||||
for _, banned := range []string{"googleapis.com/maps", "mapbox", "openstreetmap", "bing.com/maps"} {
|
||||
require.NotContains(t, lower, banned)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTMLEmpty 覆盖空窗口:不发一堆空表格,给一句结论。
|
||||
func TestRenderHTMLEmpty(t *testing.T) {
|
||||
loc := time.Local
|
||||
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
|
||||
sum := Build(Input{
|
||||
Period: Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)},
|
||||
Now: start,
|
||||
})
|
||||
html := RenderHTML(sum, HTMLOptions{})
|
||||
require.Contains(t, html, "本期没有捕获到任何蜜罐命中")
|
||||
require.NotContains(t, html, "<table>", "空报告不该渲染数据表")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta name="format-detection" content="email=no"/>
|
||||
<meta name="format-detection" content="date=no"/>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
|
||||
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
|
||||
<tbody>
|
||||
<tr height="32" style="height: 32px;">
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td>
|
||||
<table border="0" cellspacing="0" cellpadding="0"
|
||||
style="padding-bottom: 20px;max-width: 640px;min-width: 320px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="8" style="width: 8px;"></td>
|
||||
<td>
|
||||
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 32px 20px;"
|
||||
align="center">
|
||||
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 20px;text-align: center; word-break: break-word;">
|
||||
<div style="font-size: 22px;">
|
||||
{{.Title}}
|
||||
</div>
|
||||
<div style="font-size: 13px; color: rgba(0,0,0,0.54); margin-top: 6px;">
|
||||
统计窗口 {{.Period}} · 生成于 {{.Generated}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding-top: 20px; text-align: left;">
|
||||
{{.SummaryHTML}}
|
||||
</div>
|
||||
|
||||
{{if .MapCID}}
|
||||
<div style="padding-top: 18px; text-align: left;">
|
||||
<div style="font-size: 13px; color: rgba(0,0,0,0.54); margin-bottom: 8px;">
|
||||
来源分布(气泡面积与命中次数成正比)
|
||||
</div>
|
||||
<img src="cid:{{.MapCID}}" alt="蜜罐命中来源世界地图"
|
||||
style="width: 100%; max-width: 600px; border: 1px solid #dadce0; border-radius: 6px; display: block;"/>
|
||||
<div style="font-size: 11px; color: rgba(0,0,0,0.38); margin-top: 6px; line-height: 18px;">
|
||||
图为 IP 库给出的城市级定位,精度到城市,不代表对方真实位置。图中只画经纬网与气泡,不含任何国界。
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div style="padding-top: 20px; text-align: left;">
|
||||
{{.CountriesHTML}}
|
||||
</div>
|
||||
|
||||
<div style="padding-top: 18px; text-align: left; font-size: 13px; color: rgba(0,0,0,0.6); line-height: 21px;">
|
||||
{{.Notes}}
|
||||
</div>
|
||||
|
||||
<div style="padding-top: 20px; border-top: thin solid #dadce0; margin-top: 20px; font-size: 12px; color: rgba(0,0,0,0.38); text-align: left; line-height: 19px;">
|
||||
完整报告(含来源明细、被探测路径、每日趋势)见附件 HTML,用浏览器打开即可。<br/>
|
||||
本邮件由 TamaBox 蜜罐自动生成,收件人应仅为站点运维本人。
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -115,6 +115,8 @@ type mockLoggedUser struct {
|
||||
Bloglink string
|
||||
ChatMaxMessages int
|
||||
DailyMessageLimit int
|
||||
// Telegram 通知的会话 ID(profile.html 的通知面板会回显)。
|
||||
TelegramChatID string
|
||||
}
|
||||
|
||||
// profileBaseData 构造 /user/profile 渲染所需的基础数据。
|
||||
|
||||
Reference in New Issue
Block a user