Files
tamakyi d690355cdc 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 个渲染测试失败。
2026-10-30 16:40:44 +08:00

477 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}