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
+476
View File
@@ -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
}