// 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 ( "encoding/base64" "fmt" "html" "math" "os" "path/filepath" "strings" "time" ) // HTMLOptions 渲染选项。 type HTMLOptions struct { // TencentMapKey 腾讯位置服务 key。留空则报告里只有离线 SVG 地图, // 并附一句「需要可交互底图请配置 ip_geo_tencent_key」。 TencentMapKey string } // tencentKeyPlaceholder 未配置 key 时写进页面的占位符。 // // 按地图合规要求,代码里绝不预置任何可用的 key;占位符要让拿到报告的人 // 一眼看出「这里需要你自己申请」,而不是以为地图坏了。 const tencentKeyPlaceholder = "请前往 lbs.qq.com 申请 key 后填入 [security] ip_geo_tencent_key" // RenderHTML 生成完整的自包含报告页。 // // 自包含的含义:不依赖 site 的 CSS、不依赖任何外链图片(腾讯地图那一块是 // 可选的在线增强,其余部分断网也能看)。 func RenderHTML(sum *Summary, opts HTMLOptions) string { var b strings.Builder title := sum.Period.Title() b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, `%s`, html.EscapeString(title)) b.WriteString(pageCSS) b.WriteString(`
`) // 头部 fmt.Fprintf(&b, `

%s

统计窗口 %s · 生成于 %s

`, html.EscapeString(title), html.EscapeString(sum.Period.Label()), sum.GeneratedAt.Format("2006-01-02 15:04")) if sum.Empty { b.WriteString(`
本期没有捕获到任何蜜罐命中 —— 这本身是个好消息,但也请确认蜜罐仍然开启(TamaBox honeypot paths)。
`) b.WriteString(`
`) return b.String() } // KPI b.WriteString(`
`) writeKPI(&b, "蜜罐命中", fmt.Sprint(sum.TotalHits), trendNote(sum.TotalHits, sum.PrevHits)) writeKPI(&b, "独立来源", fmt.Sprint(sum.UniqueSources), fmt.Sprintf("覆盖 %d 个国家和地区", sum.UniqueCountry)) writeKPI(&b, "已拦截", fmt.Sprint(sum.BannedHits), pct(sum.BannedHits, sum.TotalHits)) writeKPI(&b, "真实破解尝试", fmt.Sprint(sum.CredHits), "提交了账号密码") writeKPI(&b, "云主机来源", fmt.Sprint(sum.CloudSources), fmt.Sprintf("占比 %s", pctInt(int64(sum.CloudSources), int64(sum.UniqueSources)))) b.WriteString(`
`) // 地图 b.WriteString(`

来源分布

`) b.WriteString(`

气泡面积与命中次数成正比。地图上的点是 IP 库给出的城市级定位,精度到城市,不代表对方的真实街道位置。

`) b.WriteString(`
`) b.WriteString(RenderSVG(sum)) b.WriteString(`
`) b.WriteString(`

⚠️ 本图只画经纬网与气泡,不含任何国界。国界、台湾与南海诸岛的正确绘制须符合国家标准,自绘底图容易出错,因此这里不做底图;需要真实底图请看下方的腾讯地图版本。

`) b.WriteString(`
`) // 腾讯可交互地图 b.WriteString(`

可交互地图

`) b.WriteString(renderTencentMap(sum, opts.TencentMapKey)) b.WriteString(`
`) // 国家排行 b.WriteString(`

国家 / 地区排行

`) b.WriteString(``) for i, c := range sum.Countries { if i >= 20 { break } fmt.Fprintf(&b, ``, i+1, html.EscapeString(c.Name), html.EscapeString(c.Code), c.Hits, pctFloat(c.Share), c.Sources) } b.WriteString(`
#国家 / 地区命中占比独立来源
%d%s %s%d%s%d
`) // 城市排行 if len(sum.Cities) > 0 { b.WriteString(`

城市排行(前 20)

`) b.WriteString(``) for i, c := range sum.Cities { if i >= 20 { break } city := c.City if city == "" { city = c.Region } if city == "" { city = "(未知城市)" } fmt.Fprintf(&b, ``, i+1, html.EscapeString(city), html.EscapeString(c.Country), c.Hits, c.Sources) } b.WriteString(`
#城市国家命中独立来源
%d%s%s%d%d
`) } // 来源明细 b.WriteString(`

来源明细(前 50)

`) b.WriteString(``) for i, s := range sum.Sources { if i >= 50 { break } provider := s.Provider if provider == "" { provider = "—" } isp := s.ISP if s.ASN != "" { if isp != "" { isp += " · " } isp += s.ASN } if isp == "" { isp = "—" } fmt.Fprintf(&b, ``, html.EscapeString(s.IP), html.EscapeString(s.Location()), html.EscapeString(isp), html.EscapeString(provider), s.Hits, s.Creds, s.Banned) } b.WriteString(`
IP位置运营商 / ASN网络归属命中破解拦截
%s%s%s%s%d%d%d
`) if len(sum.Sources) >= 50 { fmt.Fprintf(&b, `

共 %d 个独立来源,此处只列命中次数最高的 50 个。完整明细:TamaBox honeypot stats --days %d

`, sum.UniqueSources, int(sum.Period.End.Sub(sum.Period.Start).Hours()/24)) } b.WriteString(`
`) // 路径 if len(sum.Paths) > 0 { b.WriteString(`

被探测路径

`) b.WriteString(``) for i, p := range sum.Paths { if i >= 20 { break } fmt.Fprintf(&b, ``, i+1, html.EscapeString(p.Path), p.Hits) } b.WriteString(`
#路径次数
%d%s%d
`) } // 趋势 if len(sum.Days) > 1 { b.WriteString(`

每日趋势

`) b.WriteString(``) for _, d := range sum.Days { w := 0.0 if sum.MaxDay > 0 { w = float64(d.Hits) / float64(sum.MaxDay) * 100 } // 宽度为 0 的日子也给 1px,否则读起来像「数据缺失」而不是「零」。 barW := math.Max(w, 0.6) fmt.Fprintf(&b, ``, d.Day.Format("01-02"), barW, d.Hits) } b.WriteString(`
%s%d
`) } // 口径说明 b.WriteString(`

口径与说明

`) fmt.Fprintf(&b, ``, sum.GeneratedAt.Format("2006-01-02 15:04:05")) b.WriteString(``) return b.String() } // renderTencentMap 生成腾讯地图区块。 // // 合规要点(与 geo-map-compliance-guard 一致): // - 只用持牌厂商的底图(腾讯位置服务),绝不引 Google/Bing/OSM/Mapbox; // - 代码里不预置任何可用 key,未配置时给出申请指引; // - 地图展示的是攻击者 IP 的城市级定位,用于内部安全复盘,不对外发布。 func renderTencentMap(sum *Summary, key string) string { key = strings.TrimSpace(key) if key == "" { return fmt.Sprintf(`
未配置腾讯地图 key,本节跳过。
如需可缩放的真实底图,%s。
`, html.EscapeString(tencentKeyPlaceholder)) } pts := bubbles(sum) if len(pts) > 120 { pts = pts[:120] // 地图上挤 500 个点既卡又看不清 } maxHits := maxCityHits(pts) var geo strings.Builder geo.WriteString("[") for i, p := range pts { if i > 0 { geo.WriteString(",") } // 腾讯地图用 GCJ-02。ip-api 给的是 WGS-84(与 GCJ-02 差几百米), // 在城市级缩放上无影响;放大到街道时才有偏差。 fmt.Fprintf(&geo, `{"id":"p%d","styleId":"s%d","position":[%f,%f],"title":"%s %d 次"}`, i, sizeClass(p.Hits, maxHits), p.Lat, p.Lon, jsEscape(p.City+p.Country), p.Hits) } geo.WriteString("]") return fmt.Sprintf(`
`, html.EscapeString(key), geo.String(), len(pts)) } // sizeClass 按命中数把气泡分成 5 档(地图标记的尺寸样式只有有限几档)。 func sizeClass(hits, maxHits int64) int { if maxHits <= 0 { return 0 } r := float64(hits) / float64(maxHits) switch { case r > 0.8: return 4 case r > 0.5: return 3 case r > 0.25: return 2 case r > 0.08: return 1 default: return 0 } } // jsEscape 转义要嵌进 JS 字符串字面量的文本。 // 先做 HTML 转义(防 逃逸),再转义引号与反斜杠。 func jsEscape(s string) string { s = html.EscapeString(s) s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\"`) s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", " ") return s } func writeKPI(b *strings.Builder, label, value, note string) { b.WriteString(`
`) fmt.Fprintf(b, `
%s
%s
`, html.EscapeString(label), html.EscapeString(value)) if note != "" { fmt.Fprintf(b, `
%s
`, html.EscapeString(note)) } b.WriteString(`
`) } // trendNote 给出与上一周期的对比。 func trendNote(cur, prev int64) string { if prev <= 0 { return "上期无数据" } delta := float64(cur-prev) / float64(prev) * 100 arrow := "↑" if delta < 0 { arrow = "↓" } return fmt.Sprintf("较上期 %s %.0f%%(上期 %d)", arrow, math.Abs(delta), prev) } func pct(v, total int64) string { if total <= 0 { return "—" } return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100) } func pctInt(v, total int64) string { return pct(v, total) } func pctFloat(f float64) string { return fmt.Sprintf("%.1f%%", f*100) } // WriteFiles 把报告落盘,返回 HTML 与 PNG 的路径。 // // 目录按 0700 创建:报告里有攻击者 IP 明细,不是可以随便给别的用户读的文件。 func WriteFiles(sum *Summary, dir string, opts HTMLOptions) (htmlPath, pngPath string, err error) { if err := os.MkdirAll(dir, 0o700); err != nil { return "", "", err } base := filepath.Join(dir, sum.Period.Filename()) htmlPath = base + ".html" if err = os.WriteFile(htmlPath, []byte(RenderHTML(sum, opts)), 0o600); err != nil { return "", "", err } png, err := RenderPNG(sum) if err != nil { return htmlPath, "", err } pngPath = base + ".png" if err = os.WriteFile(pngPath, png, 0o600); err != nil { return htmlPath, "", err } return htmlPath, pngPath, nil } // PNGDataURI 把地图转成 data URI(备用:某些邮件客户端对 CID 附件不友好时 // 可以退化为内联 data URI —— 代价是邮件体积变大且部分客户端会拦)。 func PNGDataURI(sum *Summary) (string, error) { b, err := RenderPNG(sum) if err != nil { return "", err } return "data:image/png;base64," + base64.StdEncoding.EncodeToString(b), nil } // pageCSS 报告页样式。刻意内联:报告的 HTML 会被单独转发、离线打开, // 不能依赖站点的静态资源(那些 URL 在别人机器上打不开)。 const pageCSS = `` // FormatTime 供模板层统一时间格式。 func FormatTime(t time.Time) string { return t.Format("2006-01-02 15:04") }