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:
tamakyi
2026-10-30 16:40:44 +08:00
parent 08bbd17751
commit d690355cdc
33 changed files with 5198 additions and 1 deletions
+82
View File
@@ -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`。
+55
View File
@@ -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 个渲染测试失败