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
+135
View File
@@ -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 "未知"
}