// 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" "encoding/json" "net/http" "strings" "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 → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应; // - 无论 GET/POST → 计入 autoban 敏感路径统计,达到阈值即临时封禁 + 邮件告警; // - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。 type honeypotHandler struct { hp *honeypot.Honeypot ab *autoban.Banner // store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。 store db.HoneypotStore // sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。 sendAlert func(mail.HoneypotAlert) error // alerted 保证同一个 IP 只告警一次(与 autoban 的告警去重口径一致), // 避免暴力破解脚本在封禁前反复提交把邮箱打爆。 alerted map[string]bool alertedMu sync.Mutex } // newHoneypotHandler 构造蜜罐处理器。 func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore) *honeypotHandler { return &honeypotHandler{ hp: hp, ab: ab, store: store, alerted: map[string]bool{}, } } // Serve 处理一次蜜罐命中。 func (h *honeypotHandler) Serve(ctx flamego.Context) { req := ctx.Request().Request ip := shared.LimitKey(shared.ResolveClientIP(req)) path := req.URL.Path // 封禁:走 autoban 的蜜罐独立通道(RecordHoneypot)。 // // 刻意不用常规的 Record:它受 enable_auto_ban 开关约束, // 而蜜罐是显式开启的功能 —— 开了就该封禁,不该被另一个开关连坐。 // 蜜罐路径与业务路由零交集,正常访客不可能命中,阈值取 1(一次即封)。 banned := false if h.ab != nil && ip != "" { banned = h.ab.RecordHoneypot(ip, path) } // body 只能读一次:先提取凭据(POST 时读 body),再决定写哪种响应。 creds := h.hp.ExtractCredentials(req) switch req.Method { case http.MethodGet, http.MethodHead: h.serveLoginPage(ctx, path) case http.MethodPost, http.MethodPut, http.MethodPatch: h.serveLoginFailure(ctx, path, honeypot.LoginFailureNotice) default: // 其他方法(PUT/DELETE/OPTIONS 等)返回一个无内容的假 200,模拟后端存在。 ctx.ResponseWriter().WriteHeader(http.StatusOK) } // 落库留证。落库失败只记日志,绝不影响已写出的响应。 h.record(ip, req, path, creds, banned) } // serveLoginPage 返回假登录页。 func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) { redir := "" if q := ctx.Request().URL.Query().Get("redirect_to"); q != "" { redir = q } honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{ Action: path, Title: "Log In ‹ WordPress", Heading: "WordPress", Redir: redir, }) } // serveLoginFailure 返回「登录失败」的假响应。 // // 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误), // 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。 func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string, notice string) { body := honeypot.RenderLoginPage(honeypot.LoginPageVars{ Action: path, Title: "Log In \u2039 WordPress", Heading: "WordPress", Notice: notice, Redir: ctx.Request().URL.Query().Get("redirect_to"), }) w := ctx.ResponseWriter() w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.Header().Set("X-Robots-Tag", "noindex, nofollow") w.WriteHeader(http.StatusOK) _, _ = w.Write(body) } // record 追加一条命中记录。 func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) { h.recordCredentials(ip, req, path, creds, banned) } // recordCredentials 把一次命中写入数据库。 func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) { provider, proxied := ipinfo.Lookup(ip) fieldsJSON := "" if len(creds.Fields) > 0 { if b, err := json.Marshal(creds.Fields); err == nil { fieldsJSON = string(b) } } query := "" if req.URL != nil { query = req.URL.RawQuery if len(query) > 1000 { query = query[:1000] } } hit := &db.HoneypotHit{ IP: ip, Method: req.Method, Path: path, Query: query, Username: creds.Username, Password: creds.Password, Fields: fieldsJSON, RawBody: creds.RawBody, 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") } } // 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 } h.alertedMu.Lock() if h.alerted[ip] { h.alertedMu.Unlock() return } h.alerted[ip] = true h.alertedMu.Unlock() 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]) } // honeypotExtraPaths 解析配置中的自定义诱饵路径(逗号/分号/换行分隔)。 func honeypotExtraPaths(raw string) []string { if strings.TrimSpace(raw) == "" { return nil } fields := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t' }) out := make([]string, 0, len(fields)) for _, f := range fields { f = strings.TrimSpace(f) if f == "" { continue } if !strings.HasPrefix(f, "/") { f = "/" + f } out = append(out, f) } return out }