// 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" "net/http" "sync" "time" "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 ip := shared.LimitKey(shared.ResolveClientIP(req)) path := req.URL.Path // 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 && ip != "" { if creds.HadFields { banned = h.ab.RecordHoneypot(ip, path) } else { h.ab.Record(ip, path, autoban.KindSensitive) } } switch req.Method { case http.MethodGet, http.MethodHead: h.serveLoginPage(ctx, path) case http.MethodPost, http.MethodPut, http.MethodPatch: h.serveLoginFailure(ctx, path) default: // 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。 ctx.ResponseWriter().WriteHeader(http.StatusOK) } // 落库留证。落库失败只记日志,绝不影响已写出的响应。 h.record(ip, req, path, creds, banned) } // serveLoginPage 返回假登录页。 func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) { h.writeLogin(ctx, path, "") } // serveLoginFailure 返回「登录失败」的假响应。 // // 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误), // 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。 func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string) { h.writeLogin(ctx, path, honeypot.LoginFailureNotice) } // writeLogin 是两种响应形态的唯一出口。 // // 之前 serveLoginFailure 手抄了一遍 WriteLoginPage 的响应头设置(Content-Type / // Cache-Control / X-Robots-Tag),已经开始漂移(标题一处写字面量 `‹`、 // 一处写 `\u2039`)。统一走 page.go,改响应头只需改一处。 func (h *honeypotHandler) writeLogin(ctx flamego.Context, path, notice string) { honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{ Action: path, Title: honeypot.LoginPageTitle, Heading: "WordPress", Notice: notice, Redir: ctx.Request().URL.Query().Get("redirect_to"), }) } // record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。 // // 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT // 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时 // 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏), // 纯路径探测按 IP + 窗口采样即可 —— 路径分布仍然保留。 func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) { if !creds.HadFields && !h.dedup.first("probe:"+ip, probeSampleWindow) { return } query := "" if req.URL != nil { query = req.URL.RawQuery } // 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。 // 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。 if creds.HadFields { h.saveCredential(ip, req, path, query, creds, banned) } provider, proxied := ipinfo.Lookup(ip) // 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields), // 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。 // 所有字符串列都按 rune 截到列宽以内。少截一处就会在 PostgreSQL 上 // 以 `value too long for type character varying(N)` 整条插入失败 —— // 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。 hit := &db.HoneypotHit{ IP: truncate(ip, 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(ip, req, path, creds, provider, proxied, banned) } // 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。 if creds.HadFields { logrus.WithFields(logrus.Fields{ "ip": ip, "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", ip).WithField("path", path). Warn("honeypot: failed to persist hit") } } // saveCredential 把一次凭据提交追加到取证文件。 // // 为什么不入库:见 honeypot.CredRecord 的注释 —— 明文账号密码一旦入库, // 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。 // 这里也不做「脱敏后入库」的折中:半吊子的脱敏既不能用于取证, // 也不能消除「凭据以某种形式留在库里」这个事实。 // // 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满 // 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。 func (h *honeypotHandler) saveCredential(ip string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) { if h.cstore == nil { logrus.WithField("ip", ip). Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存") return } rec := honeypot.CredRecord{ Time: time.Now(), IP: truncate(ip, 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", ip).Warn("蜜罐凭据写入文件失败") } } // alert 发送一次告警邮件(每个 IP 只发一次)。 func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) { if h.sendAlert == nil || ip == "" { return } // 同一 IP 在冷却期内只发一封:爆破脚本会在封禁前反复 POST, // 不去重会把邮箱打爆(且每个源 IP 都是一封)。 if !h.dedup.first("alert:"+ip, 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: ip, Provider: provider, Proxied: proxied, Path: path, Username: creds.Username, Password: creds.Password, Banned: banned, DurationMin: durationMin, }); err != nil { logrus.WithError(err).WithField("ip", ip).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]) }