Files
tamabox/internal/security/report/html.go
T
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

392 lines
16 KiB
Go
Raw 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 (
"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(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">`)
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1">`)
fmt.Fprintf(&b, `<title>%s</title>`, html.EscapeString(title))
b.WriteString(pageCSS)
b.WriteString(`</head><body><div class="wrap">`)
// 头部
fmt.Fprintf(&b, `<header><h1>%s</h1><p class="sub">统计窗口 <b>%s</b> · 生成于 %s</p></header>`,
html.EscapeString(title), html.EscapeString(sum.Period.Label()),
sum.GeneratedAt.Format("2006-01-02 15:04"))
if sum.Empty {
b.WriteString(`<div class="empty">本期没有捕获到任何蜜罐命中 —— 这本身是个好消息,但也请确认蜜罐仍然开启(<code>TamaBox honeypot paths</code>)。</div>`)
b.WriteString(`</div></body></html>`)
return b.String()
}
// KPI
b.WriteString(`<section class="kpis">`)
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(`</section>`)
// 地图
b.WriteString(`<section><h2>来源分布</h2>`)
b.WriteString(`<p class="note">气泡面积与命中次数成正比。地图上的点是 IP 库给出的城市级定位,精度到城市,不代表对方的真实街道位置。</p>`)
b.WriteString(`<div class="map-frame">`)
b.WriteString(RenderSVG(sum))
b.WriteString(`</div>`)
b.WriteString(`<p class="note">⚠️ 本图只画经纬网与气泡,<b>不含任何国界</b>。国界、台湾与南海诸岛的正确绘制须符合国家标准,自绘底图容易出错,因此这里不做底图;需要真实底图请看下方的腾讯地图版本。</p>`)
b.WriteString(`</section>`)
// 腾讯可交互地图
b.WriteString(`<section><h2>可交互地图</h2>`)
b.WriteString(renderTencentMap(sum, opts.TencentMapKey))
b.WriteString(`</section>`)
// 国家排行
b.WriteString(`<section><h2>国家 / 地区排行</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>国家 / 地区</th><th class="num">命中</th><th class="num">占比</th><th class="num">独立来源</th></tr></thead><tbody>`)
for i, c := range sum.Countries {
if i >= 20 {
break
}
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s <span class="muted">%s</span></td><td class="num">%d</td><td class="num">%s</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(c.Name), html.EscapeString(c.Code), c.Hits, pctFloat(c.Share), c.Sources)
}
b.WriteString(`</tbody></table></section>`)
// 城市排行
if len(sum.Cities) > 0 {
b.WriteString(`<section><h2>城市排行(前 20)</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>城市</th><th>国家</th><th class="num">命中</th><th class="num">独立来源</th></tr></thead><tbody>`)
for i, c := range sum.Cities {
if i >= 20 {
break
}
city := c.City
if city == "" {
city = c.Region
}
if city == "" {
city = "(未知城市)"
}
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s</td><td>%s</td><td class="num">%d</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(city), html.EscapeString(c.Country), c.Hits, c.Sources)
}
b.WriteString(`</tbody></table></section>`)
}
// 来源明细
b.WriteString(`<section><h2>来源明细(前 50)</h2>`)
b.WriteString(`<table><thead><tr><th>IP</th><th>位置</th><th>运营商 / ASN</th><th>网络归属</th><th class="num">命中</th><th class="num">破解</th><th class="num">拦截</th></tr></thead><tbody>`)
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, `<tr><td class="mono">%s</td><td>%s</td><td class="small">%s</td><td class="small">%s</td><td class="num">%d</td><td class="num">%d</td><td class="num">%d</td></tr>`,
html.EscapeString(s.IP), html.EscapeString(s.Location()),
html.EscapeString(isp), html.EscapeString(provider),
s.Hits, s.Creds, s.Banned)
}
b.WriteString(`</tbody></table>`)
if len(sum.Sources) >= 50 {
fmt.Fprintf(&b, `<p class="note">共 %d 个独立来源,此处只列命中次数最高的 50 个。完整明细:<code>TamaBox honeypot stats --days %d</code></p>`,
sum.UniqueSources, int(sum.Period.End.Sub(sum.Period.Start).Hours()/24))
}
b.WriteString(`</section>`)
// 路径
if len(sum.Paths) > 0 {
b.WriteString(`<section><h2>被探测路径</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>路径</th><th class="num">次数</th></tr></thead><tbody>`)
for i, p := range sum.Paths {
if i >= 20 {
break
}
fmt.Fprintf(&b, `<tr><td>%d</td><td class="mono">%s</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(p.Path), p.Hits)
}
b.WriteString(`</tbody></table></section>`)
}
// 趋势
if len(sum.Days) > 1 {
b.WriteString(`<section><h2>每日趋势</h2>`)
b.WriteString(`<table class="trend"><tbody>`)
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, `<tr><td class="day">%s</td><td class="bar"><span style="width:%.2f%%"></span></td><td class="num">%d</td></tr>`,
d.Day.Format("01-02"), barW, d.Hits)
}
b.WriteString(`</tbody></table></section>`)
}
// 口径说明
b.WriteString(`<section><h2>口径与说明</h2><ul class="meta">`)
fmt.Fprintf(&b, `<li>地理定位数据源:%s。</li>`, html.EscapeString(sum.ResolverDesc))
fmt.Fprintf(&b, `<li>未能定位的命中:<b>%d</b> 次(占 %s)。这些 IP 在国内外的 IP 库里都没有城市级记录,通常是被回收中的地址段或运营商级 NAT。</li>`,
sum.UnlocatedHits, pct(sum.UnlocatedHits, sum.TotalHits))
b.WriteString(`<li>「破解」指对该次请求提交了账号密码字段;纯路径探测不计入。捕获到的账号密码本身不写进本报告,存放在服务器的取证目录(<code>TamaBox honeypot hits</code> 查看)。</li>`)
b.WriteString(`<li>「网络归属」来自离线指纹(cdncheck),判定的是云厂商 / CDN / WAF,<b>不是</b>地理位置。显示「未识别(普通网络)」不代表没定位到,两者是不同的维度。</li>`)
b.WriteString(`<li>独立来源按封禁口径聚合:IPv4 精确到地址,IPv6 折叠到 /64 —— 攻击者常在一个 /64 内轮换地址,按精确地址会把一台机器算成几十个。</li>`)
b.WriteString(`</ul></section>`)
fmt.Fprintf(&b, `<footer>由 TamaBox 蜜罐自动生成 · %s</footer>`, sum.GeneratedAt.Format("2006-01-02 15:04:05"))
b.WriteString(`</div></body></html>`)
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(`<div class="hint">未配置腾讯地图 key,本节跳过。<br>如需可缩放的真实底图,%s。</div>`,
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(`<div id="tmap" class="tmap"></div>
<div id="tmap-status" class="note"></div>
<script src="https://map.qq.com/api/gljs?v=1.exp&key=%s"></script>
<script>
(function () {
if (typeof TMap === 'undefined') {
document.getElementById('tmap-status').textContent = '腾讯地图 SDK 未能加载(可能是离线打开或 key 无效),请参考上方离线地图。';
return;
}
var center = new TMap.LatLng(20, 10);
var map = new TMap.Map('tmap', { center: center, zoom: 3, viewMode: '2D' });
var styles = {};
var sizes = [[8,8],[12,12],[18,18],[26,26],[34,34]];
for (var i = 0; i < sizes.length; i++) {
styles['s' + i] = new TMap.MarkerStyle({
width: sizes[i][0], height: sizes[i][1],
anchor: { x: sizes[i][0]/2, y: sizes[i][1]/2 },
color: '#EF4444'
});
}
new TMap.MultiMarker({ map: map, styles: styles, geometries: %s });
document.getElementById('tmap-status').textContent = '共 %d 个城市级定位点,气泡越大命中越多。点击标记可见城市与次数。';
})();
</script>`,
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 转义(防 </script> 逃逸),再转义引号与反斜杠。
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(`<div class="kpi">`)
fmt.Fprintf(b, `<div class="kpi-label">%s</div><div class="kpi-value">%s</div>`, html.EscapeString(label), html.EscapeString(value))
if note != "" {
fmt.Fprintf(b, `<div class="kpi-note">%s</div>`, html.EscapeString(note))
}
b.WriteString(`</div>`)
}
// 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 = `<style>
:root{--fg:#0f172a;--muted:#64748b;--line:#e2e8f0;--bg:#ffffff;--accent:#dc2626;--tint:#f8fafc}
*{box-sizing:border-box}
body{margin:0;background:var(--tint);color:var(--fg);font:14px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
.wrap{max-width:1040px;margin:0 auto;padding:32px 20px 60px}
header h1{margin:0 0 4px;font-size:24px;letter-spacing:-.01em}
header .sub{margin:0;color:var(--muted);font-size:13px}
section{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:20px;margin-top:18px}
h2{font-size:15px;margin:0 0 12px;font-weight:600}
.note{color:var(--muted);font-size:12.5px;margin:8px 0 0}
.muted{color:var(--muted)}
.small{font-size:12.5px;color:var(--muted)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:18px}
.kpi{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.kpi-label{font-size:12px;color:var(--muted)}
.kpi-value{font-size:26px;font-weight:650;letter-spacing:-.02em;margin:2px 0}
.kpi-note{font-size:12px;color:var(--muted)}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-weight:600;color:var(--muted);font-size:12px;background:var(--tint)}
.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.map-frame{border:1px solid var(--line);border-radius:8px;overflow:hidden;line-height:0}
.tmap{height:420px;border:1px solid var(--line);border-radius:8px}
.hint{background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:12px 14px;color:#9a3412;font-size:13px}
.empty{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:28px;text-align:center;color:var(--muted)}
table.trend td{border:0;padding:2px 8px}
table.trend .day{color:var(--muted);font-size:12px;white-space:nowrap;width:60px}
table.trend .bar{padding:0 8px}
table.trend .bar span{display:block;height:12px;background:linear-gradient(90deg,#f87171,#dc2626);border-radius:2px;min-width:1px}
table.trend .num{width:70px}
ul.meta{margin:0;padding-left:18px;color:#334155;font-size:13px}
ul.meta li{margin-bottom:6px}
footer{margin-top:26px;text-align:center;color:var(--muted);font-size:12px}
code{background:var(--tint);border:1px solid var(--line);border-radius:4px;padding:1px 5px;font-size:12px}
@media print{body{background:#fff}section{break-inside:avoid;border-color:#ccc}}
</style>`
// FormatTime 供模板层统一时间格式。
func FormatTime(t time.Time) string { return t.Format("2006-01-02 15:04") }