Files
tamabox/internal/route/honeypot.go
T
tamakyi 307bd94ed1 feat(honeypot): /xmlrpc.php 走真实 XML-RPC,动态 token 不再恒定
线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装:

一、/xmlrpc.php 回的是 HTML 登录页(P1)
我们自己发的响应头里就有 X-Pingback: /xmlrpc.php —— 扫描器顺着跟过来,
一看是 HTML 就知道整站是假的(真实 WordPress 在这里回 text/xml)。
更可惜的是 system.multicall 这条通道被浪费:一次请求能带上千组用户名/密码、
且绕开登录页限速插件,是 WP 爆破里产出最高的入口。

新增 xmlrpc.go:
- 非 POST → 405 + fault「XML-RPC services are disabled on this site.」(与真实 WP 一致)
- POST methodCall → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
- POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
- 凭据提取刻意不上 encoding/xml(蜜罐被高频命中):抓所有 <string>、剔除
  methodName 与站点 URL 后两两配对。wp.getUsersBlogs 的 blog_id 若是短数字会让
  配对错位一位,故「奇数长度 + 首项短数字」时丢首项;另外原始串全量写进
  fields(xmlrpc.0/1/…),配对失败也不丢原文。
  multicall 的第一组进标准字段,其余进 cred2/cred3… —— 一个用户名配一长串
  密码是常见字典形态,只留第一组等于丢掉整份字典。
- 封禁与表单提交同规则:带凭据走 RecordHoneypot(立即封),否则走常规阈值。

二、进入页面的隐藏值每次请求都一样(P2)
两次请求拿到同一个 pma token / csrf_token / X-Jenkins-Session,任何脚本都能
判定对面是伪造的。改为按请求生成 32 位 hex(math/rand/v2 —— 这些是假 token,
不需要不可预测,只需要每次不同;crypto/rand 在 Windows 要走系统调用,贵一个量级),
页头、hidden 与 Set-Cookie 共用同一套。

顺带:
- X-Pingback 改绝对地址($base/xmlrpc.php)。真实 WP 给绝对 URL,相对路径本身
  就是破绽;没配 external_url 时干脆不发这个头。
- Cache-Control 从 no-store 改 no-cache, must-revalidate, max-age=0
  (no-store 更像安全设备/代理加的,不像登录页)。
- Grafana / GitLab 补上真实产品会种的会话 cookie(grafana_sess、_gitlab_session)。
- Match 增加 .php 变体兜底:清单里是 /phpinfo,扫描器打 /phpinfo.php,
  原来漏掉 → 302 回首页,等于明确告诉对方「这个文件不存在」。

测试:xmlrpc_test(IsXMLRPCPath / getUsersBlogs / multicall / 数字 blog_id /
非 XML-RPC body / fault 与 listMethods 输出)、family_test(动态 token 两次不同且
是合法 hex、X-Jenkins-Session 与 correlation_id 动态、Pingback 绝对地址与缺省不发、
所有家族渲染后不残留 $ 占位符)、route 层(POST multicall 捕获凭据 + 立即封 +
回 text/xml;GET 回 405 fault 而非登录页)、honeypot(.php 变体命中且不牵连业务路径)。
四处变异验证均按预期变红:固定 token、xmlrpc 分支失效、Pingback 退化成相对路径、
.php 兜底失效。

性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),
AssetFor 仍 0 分配。
2026-10-23 12:58:48 +08:00

515 lines
21 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 route
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
"unicode"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
"github.com/tamakyi/TamaBox/internal/shared"
)
// honeypotHandler 蜜罐处理器:只在「无任何路由匹配」的路径上生效。
//
// 与真实业务路由(/login、/register、/_/{domain}、/user/**、/api/v1/**)天然零交集,
// 因为它挂在 f.NotFound 上 —— flamego 只有在路由表完全落空时才会走到这里。
//
// 行为:
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
// - 提交了凭据 → 立即封禁(RecordHoneypot,不受 enable_auto_ban 约束)+ 告警邮件;
// - 纯路径探测 → 只计入常规敏感路径阈值,不立即封禁。必须留出「咬钩」的机会:
// 爆破脚本先 GET 探表单再 POST 提交,GET 就把人封了等于永远拿不到凭据;
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
//
// 蜜罐取证的去重/采样参数。
const (
// alertDedupTTL 同一个 IP 的告警冷却时间。旧实现是「永久只发一次」,
// 直接让 map 随源 IP 数量无界增长;24 小时既能压住噪音,也允许
// 同一 IP 在长期攻击中再次提醒。
alertDedupTTL = 24 * time.Hour
// probeSampleWindow 纯路径探测(无凭据)的落库采样窗口:
// 同一 IP 在窗口内最多落一行,保留「谁扫了哪些路径」的分布,
// 但单个 IP 的洪泛不会变成等量 INSERT。
probeSampleWindow = time.Minute
// maxDedupEntries 去重表容量上限,超出后先清过期项、仍超则按容量硬裁。
maxDedupEntries = 20000
)
// deduper 是一个带过期时间的 best-effort 去重器。
//
// 用「带 TTL 的 map」而不是永不过期的 map[string]bool:
// 蜜罐的键完全由外部流量决定(源 IP),后者等于把内存交给攻击者控制。
type deduper struct {
mu sync.Mutex
seen map[string]time.Time
}
func newDeduper() *deduper {
return &deduper{seen: make(map[string]time.Time)}
}
// first 报告 key 是否在窗口内首次出现;首次会登记,过期时间为 now+ttl。
// ttl <= 0 表示永不过期(仍受容量上限约束)。
func (d *deduper) first(key string, ttl time.Duration) bool {
if key == "" {
return true
}
now := time.Now()
d.mu.Lock()
defer d.mu.Unlock()
if until, ok := d.seen[key]; ok {
if ttl <= 0 || until.After(now) {
return false
}
}
d.seen[key] = now.Add(ttl)
if len(d.seen) > maxDedupEntries {
d.pruneLocked(now)
}
return true
}
// pruneLocked 先清过期项;仍超上限时按容量硬裁到一半。
// 硬裁是 best-effort:宁可偶尔漏一次去重,也不能让内存无界增长。
func (d *deduper) pruneLocked(now time.Time) {
for k, until := range d.seen {
if !until.IsZero() && !until.After(now) {
delete(d.seen, k)
}
}
limit := maxDedupEntries / 2
for k := range d.seen {
if len(d.seen) <= limit {
break
}
delete(d.seen, k)
}
}
type honeypotHandler struct {
hp *honeypot.Honeypot
ab *autoban.Banner
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
store db.HoneypotStore
// cstore 是凭据取证文件的存储,允许为 nil(目录不可写时降级)。
// 用户名/密码只走这里,绝不写进 store(数据库)。
cstore honeypot.CredStore
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
sendAlert func(mail.HoneypotAlert) error
// dedup 同时承担「告警每 IP 冷却」与「探测落库采样」。
dedup *deduper
}
// newHoneypotHandler 构造蜜罐处理器。
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore, cstore honeypot.CredStore) *honeypotHandler {
return &honeypotHandler{
hp: hp,
ab: ab,
store: store,
cstore: cstore,
dedup: newDeduper(),
}
}
// Serve 处理一次蜜罐命中。
func (h *honeypotHandler) Serve(ctx flamego.Context) {
req := ctx.Request().Request
// 两个不同的东西,必须分开用:
//
// clientIP —— 精确客户端地址(IPv6 保留完整地址),取证与告警展示用;
// banKey —— 聚合键(shared.LimitKey,IPv6 折成 /64),封禁与采样去重用。
//
// 曾经两者混用(都取 banKey),后果是取证只留下 /64 前缀:运维收到
// 「你的 IP 已被封禁」时发现邮件里那个地址根本不是自己的,排查方向直接被带偏;
// 而且精确地址一丢,拿去查其他日志或做 abuse 投诉就再也对不上了。
clientIP := shared.ResolveClientIP(req)
banKey := shared.LimitKey(clientIP)
// 反代/网关可能把 URI 带上首尾空白:实测 Cloudflare → nginx → 源站 链路上
// 出现过 ` /wp-login.php`。Match 内部会 normalize(含 TrimSpace),所以能命中,
// 但落库与假表单的 action 用的是原始值 —— 会留下脏取证数据,
// 甚至让爆破脚本把表单提交到错误路径。这里统一清洗一次。
path := cleanPath(req.URL.Path)
// 静态资源短路(必须放在最前面)。
//
// 真实后台登录页会加载一堆 CSS/JS/图标;这些请求如果也走后面的落库逻辑,
// 一次页面加载就能放大出十几条 INSERT,等于帮攻击者把写入成本转嫁给我们。
// 这里只回占位内容:不解析 IP、不判定封禁、不读 body、不记录 ——
// 已封禁的来源在中间件里就被挡掉了,轮不到这里。
if body, ctype, status, isAsset := honeypot.AssetFor(path); isAsset {
w := ctx.ResponseWriter()
w.Header().Set("Content-Type", ctype)
w.Header().Set("Cache-Control", "public, max-age=3600")
w.WriteHeader(status)
if len(body) > 0 {
_, _ = w.Write(body)
}
return
}
// XML-RPC 端点单独处理:真实 WordPress 在这里回 XML,而不是登录页。
// 我们自己的 X-Pingback 头正指着这个地址,回 HTML 等于自曝。
if honeypot.IsXMLRPCPath(path) {
h.serveXMLRPC(ctx, clientIP, banKey, path)
return
}
// 家族决定用哪种后台的外观与字段名:WordPress / phpMyAdmin / Jenkins /
// Tomcat / Grafana / GitLab / OpenWrt / Joomla / 通用后台。
fam := honeypot.FamilyOf(path)
// 诊断:清洗过说明上游注入了空白/零宽字符。用 %q 打出来 ——
// 这类字符肉眼不可见,裸打印会看不出哪里不同(曾经为此排查了一轮)。
if raw := req.URL.Path; raw != path {
logrus.WithFields(logrus.Fields{
"ip": clientIP,
"ban_key": banKey,
"raw": fmt.Sprintf("%q", raw),
"used": fmt.Sprintf("%q", path),
}).Warn("蜜罐路径含上游注入的空白/零宽字符,已清洗后使用")
}
// 来源 IP 是回环地址 + 未开 trust_proxy,是「站点在反代后面但没配信任」的
// 典型信号。此时所有访客在源站眼里都是同一个 IP:
// 取证数据失去意义,且一次封禁会连带封掉整站(包括普通访客)。
// 只告警不改变行为 —— 本地直连调试时回环 IP 是正常的。
if clientIP != "" && !conf.Security.TrustProxy && isLoopbackIP(clientIP) {
if h.dedup.first("warn:loopback", 24*time.Hour) {
logrus.Warn("蜜罐记录的来源 IP 是回环地址(127.0.0.1 / ::1):站点很可能在反向代理后面," +
"[security] trust_proxy 未开启。此时所有访客共享同一个 IP —— 取证失去意义," +
"且一次封禁会连带封掉整站。请配置 trust_proxy = true 与 trusted_proxies。")
}
}
// body 只能读一次:先提取凭据(POST 时读 body),再决定封禁与响应。
creds := h.hp.ExtractCredentials(req)
// 封禁分两条路,这是蜜罐能不能「钓到东西」的关键:
//
// - 提交了凭据(POST 带 log/pwd)→ 走蜜罐独立通道 RecordHoneypot,
// 不受 enable_auto_ban 约束,阈值默认 1,当场封禁。这才是证据最强的行为。
// - 纯路径探测(GET 看一眼假登录页)→ 走常规 Record(阈值默认 10 次 /
// 60 秒)。绝不能一次就封:爆破脚本的标准流程是「先 GET 探测表单再 POST
// 提交」,GET 就把人封了等于假登录页只被看了一次,对方根本没机会填表,
// 凭据无从谈起 —— 而这个功能的全部价值就在那份凭据上。
//
// 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。
banned := false
if h.ab != nil && banKey != "" {
if creds.HadFields {
banned = h.ab.RecordHoneypot(banKey, path)
} else {
h.ab.Record(banKey, path, autoban.KindSensitive)
}
}
switch req.Method {
case http.MethodGet, http.MethodHead:
h.writeLogin(ctx, fam, path, false)
case http.MethodPost, http.MethodPut, http.MethodPatch:
// 提交后仍返回该家族的登录页,带自己的失败文案(Tomcat 家族则是 401)。
h.writeLogin(ctx, fam, path, true)
default:
// 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。
ctx.ResponseWriter().WriteHeader(http.StatusOK)
}
// 落库留证。落库失败只记日志,绝不影响已写出的响应。
h.record(clientIP, banKey, req, path, creds, banned)
}
// cleanPath 去掉路径首尾的空白与零宽字符,并保证前导斜杠。
//
// 为什么不能只用 strings.TrimSpace:它只认 Unicode White_Space,
// 而 U+200B(零宽空格)/ U+200C / U+200D / U+FEFF(BOM)都属于「看不见但不算空白」
// 的字符 —— 反代或某些 WAF 改写 URI 时可能注入它们,TrimSpace 会原样放过,
// 甚至因为首字符不是 '/' 而额外补一个斜杠,拼出 "/\u200b/wp-login.php" 这种怪路径。
//
// 刻意只处理首尾:中间的零宽字符可能是攻击 payload 的一部分,取证要保留原样。
// 也刻意不改大小写:URL 路径大小写敏感。
func cleanPath(p string) string {
p = strings.TrimFunc(p, func(r rune) bool {
switch r {
case '\u200b', '\u200c', '\u200d', '\ufeff':
return true
}
return unicode.IsSpace(r)
})
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return p
}
// isLoopbackIP 判断归一化后的限流键是否指向本机回环地址。
//
// 不用 net.ParseIP:shared.LimitKey 会把 IPv6 改写成 "xxxx::/64" 这种带前缀长度的
// 形式,ParseIP 解析不了;这里按语义做前缀判断即可。
func isLoopbackIP(ip string) bool {
host := ip
if i := strings.Index(host, "/"); i >= 0 {
host = host[:i]
}
if host == "::" || host == "::1" {
return true
}
return strings.HasPrefix(host, "127.")
}
// writeLogin 是假登录响应的唯一出口。
//
// 按家族渲染(WordPress / phpMyAdmin / Jenkins / Tomcat …),families 见
// honeypot.FamilyOf:扫描器会按路径先判断「这是什么后台」,再拿它认识的
// 字段名去填;所有路径都回 WordPress 页面等于告诉对方「这里没有你要找的东西」。
//
// failed 表示这是提交后的响应,需要显示该家族自己的失败文案
// (不区分「用户不存在」与「密码错误」,避免给爆破脚本用户名枚举信号)。
//
// 之前这里手抄过一遍响应头,已经漂移(标题一处字面量 `‹`、一处 `\u2039`);
// 现在响应头也归 page.go 统一设置,改一处即可。
func (h *honeypotHandler) writeLogin(ctx flamego.Context, fam honeypot.Family, path string, failed bool) {
honeypot.WriteFamilyPage(ctx.ResponseWriter(), fam, honeypot.LoginPageVars{
Action: path,
Failed: failed,
Redir: ctx.Request().URL.Query().Get("redirect_to"),
BaseURL: conf.App.ExternalURL,
})
}
// serveXMLRPC 处理 /xmlrpc.php。
//
// 为什么单独做:system.multicall 一次请求能带上千组用户名/密码,且绕开登录页的
// 限速插件 —— 这是 WordPress 爆破里产出最高的一条通道,xxer、WPScan 的 xmlrpc
// 模式都走它。原来的实现把这个路径当普通诱饵,回一个 HTML 登录页:
// 既拿不到字典,又因为 X-Pingback 指着它而暴露「整个后台是假的」。
//
// 响应形态对齐真实 WordPress:
// - 非 POST(GET/PUT/…)→ 405 + fault「XML-RPC services are disabled on this site.」
// - POST + 凭据 → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
// - POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
func (h *honeypotHandler) serveXMLRPC(ctx flamego.Context, clientIP, banKey, path string) {
req := ctx.Request().Request
w := ctx.ResponseWriter()
if req.Method != http.MethodPost {
// 纯探测:计入常规敏感路径统计(阈值同其它诱饵),不立即封。
if h.ab != nil && banKey != "" {
h.ab.Record(banKey, path, autoban.KindSensitive)
}
h.record(clientIP, banKey, req, path, honeypot.Credentials{}, false)
honeypot.WriteXMLRPCFault(w, http.StatusMethodNotAllowed, 405, honeypot.XMLRPCFaultDisabled)
return
}
// body 只能读一次,提取完就不能再给后面的通用逻辑用了。
creds := honeypot.ExtractXMLRPCCredentials(req)
// 与表单提交同一套判定:带凭据 → 蜜罐通道立即封;否则走常规阈值。
banned := false
if h.ab != nil && banKey != "" {
if creds.HadFields {
banned = h.ab.RecordHoneypot(banKey, path)
} else {
h.ab.Record(banKey, path, autoban.KindSensitive)
}
}
if !creds.HadFields && creds.Fields["xmlrpc.method"] == "system.listMethods" {
honeypot.WriteXMLRPCMethods(w)
} else {
honeypot.WriteXMLRPCFault(w, http.StatusOK, 403, honeypot.XMLRPCFaultBadLogin)
}
h.record(clientIP, banKey, req, path, creds, banned)
}
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
//
// clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。
// 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT
// 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时
// 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏),
// 纯路径探测按来源 + 窗口采样即可 —— 路径分布仍然保留。
func (h *honeypotHandler) record(clientIP, banKey string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
// 采样按聚合键:同一 /64 内轮换地址的探测属于同一个来源,不必各存一条。
if !creds.HadFields && !h.dedup.first("probe:"+banKey, probeSampleWindow) {
return
}
query := ""
if req.URL != nil {
query = req.URL.RawQuery
}
// 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。
// 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。
if creds.HadFields {
h.saveCredential(clientIP, req, path, query, creds, banned)
}
provider, proxied := ipinfo.Lookup(clientIP)
// 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields),
// 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。
// 所有字符串列都按 rune 截到列宽以内。少截一处就会在 PostgreSQL 上
// 以 `value too long for type character varying(N)` 整条插入失败 ——
// 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。
hit := &db.HoneypotHit{
IP: truncate(clientIP, 64),
Method: truncate(req.Method, 16),
Path: truncate(path, 500),
Query: truncate(query, 1000),
HadFields: creds.HadFields,
UserAgent: truncate(req.UserAgent(), 500),
Referer: truncate(req.Referer(), 500),
ContentType: truncate(req.Header.Get("Content-Type"), 250),
Provider: provider,
Proxied: proxied,
Banned: banned,
CreatedAt: time.Now(),
}
// 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值,
// 而爆破脚本往往在封禁前反复 POST,只告警一次即可。
if creds.HadFields {
h.alert(clientIP, banKey, req, path, creds, provider, proxied, banned)
}
// 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。
if creds.HadFields {
logrus.WithFields(logrus.Fields{
"ip": clientIP,
"ban_key": banKey,
"path": path,
"username": creds.Username,
"banned": banned,
}).Warn("honeypot: credential submission captured")
}
if h.store == nil {
return
}
// 用独立超时上下文,避免请求被取消时取证写不进去。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := h.store.Create(ctx, hit); err != nil {
logrus.WithError(err).WithField("ip", clientIP).WithField("path", path).
Warn("honeypot: failed to persist hit")
}
}
// saveCredential 把一次凭据提交追加到取证文件。
//
// 为什么不入库:见 honeypot.CredRecord 的注释 —— 明文账号密码一旦入库,
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
// 这里也不做「脱敏后入库」的折中:半吊子的脱敏既不能用于取证,
// 也不能消除「凭据以某种形式留在库里」这个事实。
//
// 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满
// 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。
// clientIP 必须是精确地址(不是 shared.LimitKey 折叠后的 /64 键):
// 这份文件是取证物,IPv6 只留前缀等于把「哪台主机」永久丢掉了。
func (h *honeypotHandler) saveCredential(clientIP string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) {
if h.cstore == nil {
logrus.WithField("ip", clientIP).
Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存")
return
}
rec := honeypot.CredRecord{
Time: time.Now(),
IP: truncate(clientIP, 64),
Method: truncate(req.Method, 16),
Path: truncate(path, 500),
Query: truncate(query, 1000),
Username: truncate(creds.Username, 512),
Password: truncate(creds.Password, 512),
Fields: creds.Fields,
RawBody: creds.RawBody,
UserAgent: truncate(req.UserAgent(), 500),
Banned: banned,
}
if err := h.cstore.Append(rec); err != nil {
logrus.WithError(err).WithField("ip", clientIP).Warn("蜜罐凭据写入文件失败")
}
}
// alert 发送一次告警邮件(同一来源在冷却期内只发一封)。
//
// clientIP 进正文(人要看的是「谁来了」,必须是精确地址);
// banKey 用作去重键,同时也让邮件能说清「实际被封的是哪个键」——
// IPv6 下两者不同(键是 /64 前缀),邮件只给其中一个都会让人误判。
func (h *honeypotHandler) alert(clientIP, banKey string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
if h.sendAlert == nil || clientIP == "" {
return
}
dedupKey := banKey
if dedupKey == "" {
dedupKey = clientIP
}
// 同一来源在冷却期内只发一封:爆破脚本会在封禁前反复 POST,
// 不去重会把邮箱打爆。按聚合键去重 —— 同一 /64 内换地址仍是同一个人。
if !h.dedup.first("alert:"+dedupKey, alertDedupTTL) {
return
}
// 邮件里报告的时长必须与 autoban 实际使用的蜜罐时长一致,
// 否则运维按邮件判断「什么时候解封」会错。
durationMin := conf.Security.HoneypotBanMinutes
if durationMin <= 0 {
durationMin = conf.Security.BanDurationMinutes
}
if durationMin <= 0 {
durationMin = 15
}
go func() {
if err := h.sendAlert(mail.HoneypotAlert{
IP: clientIP,
BanKey: banKey,
Provider: provider,
Proxied: proxied,
Path: path,
Username: creds.Username,
Password: creds.Password,
Banned: banned,
DurationMin: durationMin,
}); err != nil {
logrus.WithError(err).WithFields(logrus.Fields{
"ip": clientIP,
"ban_key": banKey,
}).Warn("honeypot: failed to send alert mail")
}
}()
}
// truncate 截断字符串到指定长度(按 rune,避免截断多字节字符)。
func truncate(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}