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
+71
View File
@@ -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>
+2
View File
@@ -115,6 +115,8 @@ type mockLoggedUser struct {
Bloglink string
ChatMaxMessages int
DailyMessageLimit int
// Telegram 通知的会话 ID(profile.html 的通知面板会回显)。
TelegramChatID string
}
// profileBaseData 构造 /user/profile 渲染所需的基础数据。