From 818fa74160f3b98b65fea4464f9b52719dbdc507 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 23 Oct 2026 10:53:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(honeypot):=20=E5=8F=96=E8=AF=81=E8=AE=B0?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E5=9C=B0=E5=9D=80=E3=80=81=E5=B0=81=E7=A6=81?= =?UTF-8?q?=E7=94=A8=20/64=20=E9=94=AE=EF=BC=8C=E5=B9=B6=E8=A1=A5=E5=BB=BA?= =?UTF-8?q?=E5=B0=81=E4=B8=8E=E6=8B=A6=E6=88=AA=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是 `2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己 仍能正常访问,看日志也无从判断到底有没有封。 根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值): - 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账; - 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。 改动: - Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64): DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者; - autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus), route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at; 中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏); - 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示), 顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了; - honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把 排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。 测试(每项都做了变异验证:改坏→确认变红→恢复): - TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、 /64 才是封禁键、同 /64 换地址仍 403; - TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调; - TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复; - TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。 --- internal/cmd/honeypot.go | 58 ++++++++- internal/cmd/honeypot_test.go | 46 +++++++ internal/db/honeypot.go | 6 +- internal/mail/mail.go | 9 +- internal/mail/mail_alert_test.go | 58 +++++++++ internal/route/autoban.go | 9 +- internal/route/honeypot.go | 87 ++++++++----- internal/route/honeypot_test.go | 147 ++++++++++++++++++++++ internal/route/route.go | 17 +++ internal/security/autoban/autoban.go | 36 ++++++ internal/security/autoban/autoban_test.go | 66 ++++++++++ internal/security/honeypot/credstore.go | 3 +- templates/mail/honeypot-alert.html | 7 +- 13 files changed, 511 insertions(+), 38 deletions(-) create mode 100644 internal/cmd/honeypot_test.go diff --git a/internal/cmd/honeypot.go b/internal/cmd/honeypot.go index 31de856..ae31337 100644 --- a/internal/cmd/honeypot.go +++ b/internal/cmd/honeypot.go @@ -7,6 +7,7 @@ package cmd import ( "context" "fmt" + "sort" "strings" "time" @@ -16,6 +17,7 @@ import ( "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/security/honeypot" + "github.com/tamakyi/TamaBox/internal/shared" ) // Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。 @@ -193,10 +195,18 @@ func runHoneypotStats(ctx *cli.Context) error { if err != nil { return errors.Wrap(err, "count honeypot hits") } - sources, err := store.TopSources(cctx, since, ctx.Int("limit")) + // 元数据表存的是精确地址(取证需要),但 IPv6 攻击者常在一个 /64 内轮换地址: + // 直接按精确 IP 排行会碎成几十条各 1 次的记录,看不出「其实是同一个人」。 + // 多取一些再按封禁同口径(/64)合并,见 mergeSourcesByKey。 + limit := ctx.Int("limit") + if limit <= 0 { + limit = 20 + } + sources, err := store.TopSources(cctx, since, limit*4) if err != nil { return errors.Wrap(err, "query top sources") } + sources = mergeSourcesByKey(sources, limit) paths, err := store.TopPaths(cctx, since, ctx.Int("limit")) if err != nil { return errors.Wrap(err, "query top paths") @@ -213,7 +223,7 @@ func runHoneypotStats(ctx *cli.Context) error { fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total) fmt.Println(strings.Repeat("-", 72)) - fmt.Println("来源 IP 排行:") + fmt.Println("来源排行(IPv6 按 /64 聚合,与封禁口径一致):") if len(sources) == 0 { fmt.Println(" 无") } @@ -495,3 +505,47 @@ func maskSecret(s string) string { return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1]) } } + +// mergeSourcesByKey 按「封禁 / 限流」的同一口径合并来源排行。 +// +// 元数据表存的是精确地址(取证需要,见 db.HoneypotHit.IP),但 IPv6 攻击者 +// 通常在一个 /64 内轮换地址:直接按精确 IP 排行会碎成几十条各 1 次的记录, +// 看不出「其实是同一台机器」。这里用 shared.LimitKey 折成 /64 —— +// 与 autoban 判定、限流的键完全一致,看到的排行就是「谁在被封」。 +// +// 代价:SQL 只取了前 limit*4 条精确地址,极端长尾可能漏算,排行本身是近似展示。 +func mergeSourcesByKey(rows []*db.HoneypotSourceStat, limit int) []*db.HoneypotSourceStat { + if len(rows) == 0 { + return rows + } + type agg struct { + count int64 + provider string + } + merged := make(map[string]*agg, len(rows)) + for _, r := range rows { + key := shared.LimitKey(r.IP) + if a, ok := merged[key]; ok { + a.count += r.Count + if a.provider == "" { + a.provider = r.Provider + } + continue + } + merged[key] = &agg{count: r.Count, provider: r.Provider} + } + out := make([]*db.HoneypotSourceStat, 0, len(merged)) + for key, a := range merged { + out = append(out, &db.HoneypotSourceStat{IP: key, Count: a.count, Provider: a.provider}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].IP < out[j].IP // 同次数时稳定排序,避免每次输出顺序抖动 + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out +} diff --git a/internal/cmd/honeypot_test.go b/internal/cmd/honeypot_test.go new file mode 100644 index 0000000..8e287e4 --- /dev/null +++ b/internal/cmd/honeypot_test.go @@ -0,0 +1,46 @@ +// 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 cmd + +import ( + "testing" + + "github.com/tamakyi/TamaBox/internal/db" +) + +// TestMergeSourcesByKey 验证来源排行按「封禁口径」合并(IPv6 折成 /64)。 +// +// 元数据表存的是精确地址(取证需要),若排行榜也按精确地址分组, +// 一个在 /64 内轮换地址的扫描器会碎成几十条各 1 次的记录 —— +// 看起来像「很多人在扫」,其实只有一台机器。合并口径必须与 autoban 一致。 +func TestMergeSourcesByKey(t *testing.T) { + rows := []*db.HoneypotSourceStat{ + {IP: "2408:8207:1234:5678::1", Count: 5, Provider: "联通"}, + {IP: "2408:8207:1234:5678::2", Count: 3}, + {IP: "1.2.3.4", Count: 4, Provider: "Azure"}, + {IP: "2408:8207:9999:0001::1", Count: 2}, + } + + got := mergeSourcesByKey(rows, 10) + if len(got) != 3 { + t.Fatalf("合并后条目数 = %d, want 3(同 /64 必须合并)", len(got)) + } + if got[0].IP != "2408:8207:1234:5678::/64" || got[0].Count != 8 { + t.Errorf("第一名 = %s / %d, want 2408:8207:1234:5678::/64 / 8", got[0].IP, got[0].Count) + } + if got[0].Provider != "联通" { + t.Errorf("合并后应保留首个非空归属,got %q", got[0].Provider) + } + if got[1].IP != "1.2.3.4" || got[1].Count != 4 { + t.Errorf("第二名 = %s / %d, want 1.2.3.4 / 4(IPv4 原样保留)", got[1].IP, got[1].Count) + } + + if limited := mergeSourcesByKey(rows, 1); len(limited) != 1 { + t.Errorf("limit=1 时条目数 = %d, want 1", len(limited)) + } + if out := mergeSourcesByKey(nil, 10); len(out) != 0 { + t.Errorf("空输入应返回空,got %d", len(out)) + } +} diff --git a/internal/db/honeypot.go b/internal/db/honeypot.go index fc28360..a41382b 100644 --- a/internal/db/honeypot.go +++ b/internal/db/honeypot.go @@ -20,7 +20,11 @@ type HoneypotHit struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `gorm:"index" json:"created_at"` - // IP 为归一化后的客户端 IP(shared.LimitKey 之后,IPv6 折叠到 /64)。 + // IP 是发起请求的**精确**客户端地址(IPv6 保留完整地址,最长 45 字符)。 + // + // 刻意不存 shared.LimitKey 折叠后的 /64 聚合键:那张表是取证物, + // 「哪台主机」必须留下来才能查其他日志、做 abuse 投诉。聚合键只适合 + // 当计数器的 key(封禁 / 限流),它进不来这里。 IP string `gorm:"size:64;index" json:"ip"` // Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。 Method string `gorm:"size:16" json:"method"` diff --git a/internal/mail/mail.go b/internal/mail/mail.go index e9d4db8..251214e 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -115,7 +115,13 @@ func SendSecurityAlertMail(ip, provider string, proxied bool, samplePaths []stri // HoneypotAlert 蜜罐捕获到破解尝试时的告警载荷。 type HoneypotAlert struct { - IP string + // IP 是精确客户端地址,正文里展示的就是它(人要对得上自己的地址)。 + IP string + // BanKey 是实际写进封禁表的键:IPv4 与 IP 相同,IPv6 是折叠后的 /64 前缀。 + // 单独给出,是为了让运维能分清「源地址」与「被封的范围」—— + // 曾经只给键,收到「你的 IP 已被封禁」的人发现邮件里的地址不是自己的, + // 排查方向直接被带偏。 + BanKey string Provider string Proxied bool Path string @@ -149,6 +155,7 @@ func SendHoneypotAlertMail(a HoneypotAlert) error { } params := map[string]string{ "ip": a.IP, + "ban_key": a.BanKey, "provider": a.Provider, "proxy_hint": proxyHint, "path": a.Path, diff --git a/internal/mail/mail_alert_test.go b/internal/mail/mail_alert_test.go index 65b6114..b69f20c 100644 --- a/internal/mail/mail_alert_test.go +++ b/internal/mail/mail_alert_test.go @@ -3,6 +3,7 @@ package mail import ( "bytes" "html/template" + "strings" "testing" "github.com/tamakyi/TamaBox/templates" @@ -57,3 +58,60 @@ func cloneParams(src map[string]string) map[string]string { } return dst } + +// TestHoneypotAlertTemplateRenders 保证蜜罐告警模板可解析,并且: +// - IPv6(ip ≠ ban_key)时额外渲染「封禁范围」,说明封的是整个 /64 前缀; +// - IPv4(ip == ban_key)时不渲染那一行,避免同一串地址出现两次; +// - 正文说明的是「写入取证文件」而不是早已过时的「已入库」(凭据不入库)。 +func TestHoneypotAlertTemplateRenders(t *testing.T) { + tmpl, err := template.ParseFS(templates.FS, "mail/honeypot-alert.html") + if err != nil { + t.Fatalf("parse template: %v", err) + } + + base := map[string]string{ + "ip": "2408:8207:1234:5678::abcd", + "ban_key": "2408:8207:1234:5678::/64", + "provider": "中国联通(住宅宽带)", + "path": "/wp-login.php", + "username": "admin", + "password": "hunter2", + "disposition": "已记录并自动临时封禁 1440 分钟", + "time": "2026-10-23 10:30:00", + "year": "2026", + } + + var v6 bytes.Buffer + if err := tmpl.Execute(&v6, base); err != nil { + t.Fatalf("execute ipv6: %v", err) + } + out := v6.String() + for _, want := range []string{ + "2408:8207:1234:5678::abcd", + "封禁范围", + "2408:8207:1234:5678::/64", + "取证文件", + } { + if !strings.Contains(out, want) { + t.Errorf("IPv6 邮件缺少 %q", want) + } + } + if strings.Contains(out, "已入库") { + t.Error("邮件仍在说「已入库」:凭据已改为写入文件,不在数据库里") + } + + // IPv4:精确地址与封禁键相同,不该重复展示「封禁范围」。 + v4 := cloneParams(base) + v4["ip"] = "203.0.113.7" + v4["ban_key"] = "203.0.113.7" + var buf4 bytes.Buffer + if err := tmpl.Execute(&buf4, v4); err != nil { + t.Fatalf("execute ipv4: %v", err) + } + if strings.Contains(buf4.String(), "封禁范围") { + t.Error("IPv4 下 ip == ban_key,不应渲染「封禁范围」行") + } + if !strings.Contains(buf4.String(), "203.0.113.7") { + t.Error("IPv4 邮件缺少来源 IP") + } +} diff --git a/internal/route/autoban.go b/internal/route/autoban.go index 86952cc..3764c14 100644 --- a/internal/route/autoban.go +++ b/internal/route/autoban.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/flamego/flamego" + "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/security/autoban" "github.com/tamakyi/TamaBox/internal/security/honeypot" @@ -25,13 +26,19 @@ func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler if ip == "" { return } + path := ctx.Request().URL.Path if b.IsBanned(ip) { + // Debug 级别:被封住的扫描器会持续请求,Info 会让日志彻底淹没。 + // 需要现场确认「封禁真的在拦」时把日志级别调到 debug 即可。 + logrus.WithFields(logrus.Fields{ + "ban_key": ip, + "path": path, + }).Debug("拒绝已封禁来源") ctx.ResponseWriter().WriteHeader(http.StatusForbidden) _, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden")) return } - path := ctx.Request().URL.Path kind := autoban.KindNone // 蜜罐命中的路径由蜜罐处理器自己计数(它需要同时知道「是否触发了封禁」 // 以便落库)。若这里再记一次,同一次请求会被计两遍,阈值实际减半。 diff --git a/internal/route/honeypot.go b/internal/route/honeypot.go index 1253674..6be73df 100644 --- a/internal/route/honeypot.go +++ b/internal/route/honeypot.go @@ -131,7 +131,16 @@ func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.Hone // Serve 处理一次蜜罐命中。 func (h *honeypotHandler) Serve(ctx flamego.Context) { req := ctx.Request().Request - ip := shared.LimitKey(shared.ResolveClientIP(req)) + // 两个不同的东西,必须分开用: + // + // 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 用的是原始值 —— 会留下脏取证数据, @@ -141,9 +150,10 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) { // 这类字符肉眼不可见,裸打印会看不出哪里不同(曾经为此排查了一轮)。 if raw := req.URL.Path; raw != path { logrus.WithFields(logrus.Fields{ - "ip": ip, - "raw": fmt.Sprintf("%q", raw), - "used": fmt.Sprintf("%q", path), + "ip": clientIP, + "ban_key": banKey, + "raw": fmt.Sprintf("%q", raw), + "used": fmt.Sprintf("%q", path), }).Warn("蜜罐路径含上游注入的空白/零宽字符,已清洗后使用") } @@ -151,7 +161,7 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) { // 典型信号。此时所有访客在源站眼里都是同一个 IP: // 取证数据失去意义,且一次封禁会连带封掉整站(包括普通访客)。 // 只告警不改变行为 —— 本地直连调试时回环 IP 是正常的。 - if ip != "" && !conf.Security.TrustProxy && isLoopbackIP(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 —— 取证失去意义," + @@ -173,11 +183,11 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) { // // 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。 banned := false - if h.ab != nil && ip != "" { + if h.ab != nil && banKey != "" { if creds.HadFields { - banned = h.ab.RecordHoneypot(ip, path) + banned = h.ab.RecordHoneypot(banKey, path) } else { - h.ab.Record(ip, path, autoban.KindSensitive) + h.ab.Record(banKey, path, autoban.KindSensitive) } } @@ -192,7 +202,7 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) { } // 落库留证。落库失败只记日志,绝不影响已写出的响应。 - h.record(ip, req, path, creds, banned) + h.record(clientIP, banKey, req, path, creds, banned) } // cleanPath 去掉路径首尾的空白与零宽字符,并保证前导斜杠。 @@ -266,12 +276,14 @@ func (h *honeypotHandler) writeLogin(ctx flamego.Context, path, notice string) { // record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。 // +// clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。 // 采样理由: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) { +// 纯路径探测按来源 + 窗口采样即可 —— 路径分布仍然保留。 +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 } @@ -283,10 +295,10 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred // 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。 // 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。 if creds.HadFields { - h.saveCredential(ip, req, path, query, creds, banned) + h.saveCredential(clientIP, req, path, query, creds, banned) } - provider, proxied := ipinfo.Lookup(ip) + provider, proxied := ipinfo.Lookup(clientIP) // 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields), // 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。 @@ -294,7 +306,7 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred // 以 `value too long for type character varying(N)` 整条插入失败 —— // 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。 hit := &db.HoneypotHit{ - IP: truncate(ip, 64), + IP: truncate(clientIP, 64), Method: truncate(req.Method, 16), Path: truncate(path, 500), Query: truncate(query, 1000), @@ -311,13 +323,14 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred // 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值, // 而爆破脚本往往在封禁前反复 POST,只告警一次即可。 if creds.HadFields { - h.alert(ip, req, path, creds, provider, proxied, banned) + h.alert(clientIP, banKey, req, path, creds, provider, proxied, banned) } // 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。 if creds.HadFields { logrus.WithFields(logrus.Fields{ - "ip": ip, + "ip": clientIP, + "ban_key": banKey, "path": path, "username": creds.Username, "banned": banned, @@ -331,7 +344,7 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred 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). + logrus.WithError(err).WithField("ip", clientIP).WithField("path", path). Warn("honeypot: failed to persist hit") } } @@ -345,15 +358,17 @@ func (h *honeypotHandler) record(ip string, req *http.Request, path string, cred // // 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满 // 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。 -func (h *honeypotHandler) saveCredential(ip string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) { +// 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", ip). + logrus.WithField("ip", clientIP). Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存") return } rec := honeypot.CredRecord{ Time: time.Now(), - IP: truncate(ip, 64), + IP: truncate(clientIP, 64), Method: truncate(req.Method, 16), Path: truncate(path, 500), Query: truncate(query, 1000), @@ -365,18 +380,26 @@ func (h *honeypotHandler) saveCredential(ip string, req *http.Request, path, que Banned: banned, } if err := h.cstore.Append(rec); err != nil { - logrus.WithError(err).WithField("ip", ip).Warn("蜜罐凭据写入文件失败") + logrus.WithError(err).WithField("ip", clientIP).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 == "" { +// 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 } - // 同一 IP 在冷却期内只发一封:爆破脚本会在封禁前反复 POST, - // 不去重会把邮箱打爆(且每个源 IP 都是一封)。 - if !h.dedup.first("alert:"+ip, alertDedupTTL) { + dedupKey := banKey + if dedupKey == "" { + dedupKey = clientIP + } + // 同一来源在冷却期内只发一封:爆破脚本会在封禁前反复 POST, + // 不去重会把邮箱打爆。按聚合键去重 —— 同一 /64 内换地址仍是同一个人。 + if !h.dedup.first("alert:"+dedupKey, alertDedupTTL) { return } @@ -391,7 +414,8 @@ func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds } go func() { if err := h.sendAlert(mail.HoneypotAlert{ - IP: ip, + IP: clientIP, + BanKey: banKey, Provider: provider, Proxied: proxied, Path: path, @@ -400,7 +424,10 @@ func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds Banned: banned, DurationMin: durationMin, }); err != nil { - logrus.WithError(err).WithField("ip", ip).Warn("honeypot: failed to send alert mail") + logrus.WithError(err).WithFields(logrus.Fields{ + "ip": clientIP, + "ban_key": banKey, + }).Warn("honeypot: failed to send alert mail") } }() } diff --git a/internal/route/honeypot_test.go b/internal/route/honeypot_test.go index 9031270..fcf6032 100644 --- a/internal/route/honeypot_test.go +++ b/internal/route/honeypot_test.go @@ -5,6 +5,7 @@ package route import ( + "context" "io" "net/http" "net/http/httptest" @@ -14,6 +15,8 @@ import ( "github.com/flamego/flamego" + "github.com/tamakyi/TamaBox/internal/conf" + "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/security/autoban" "github.com/tamakyi/TamaBox/internal/security/honeypot" ) @@ -360,3 +363,147 @@ func TestHoneypotBanBlocksBusinessRoutes(t *testing.T) { t.Errorf("status = %d, want 403(被封禁的 IP 不应还能访问业务路由)", resp2.StatusCode) } } + +// TestHoneypotRecordsExactIPv6ButBansByPrefix 验证「取证地址」与「封禁键」是两个东西。 +// +// 线上踩过:用 IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是 +// 2408:…::/64 —— 既不像自己的地址,也说不清到底封了多大范围, +// 排查方向直接被带偏。现在取证与邮件给精确地址,封禁仍用 /64 聚合键 +// (抗「单个 /64 内轮换地址」绕过)。两个断言都必须成立,否则说明又混用了。 +func TestHoneypotRecordsExactIPv6ButBansByPrefix(t *testing.T) { + oldTrust := conf.Security.TrustProxy + conf.Security.TrustProxy = true + t.Cleanup(func() { conf.Security.TrustProxy = oldTrust }) + + cs, err := honeypot.NewFileCredStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // 落库路径也要断言:文件与 DB 用的是同一个 clientIP,但任一处写错 + // 都会让取证「只剩前缀」,而单测只有把它抓在手里才知道。 + store := &captureStore{} + + f := flamego.New() + hp := honeypot.New(honeypot.Options{Enabled: true}) + ab := autoban.New(autoban.Options{ + Enabled: true, + HoneypotEnabled: true, + ThresholdSensitive: 10, + Window: time.Minute, + Duration: time.Hour, + HoneypotThreshold: 1, + HoneypotDuration: 24 * time.Hour, + }) + hph := newHoneypotHandler(hp, ab, store, cs) + f.Use(autobanMiddleware(ab, hp)) + f.NotFound(func(ctx flamego.Context) { + if hp.Match(ctx.Request().URL.Path) { + hph.Serve(ctx) + return + } + ctx.Redirect("/") + }) + ts := httptest.NewServer(f) + t.Cleanup(ts.Close) + + const clientIP = "2408:8207:1234:5678::abcd" + const banKey = "2408:8207:1234:5678::/64" + + post, err := http.NewRequest(http.MethodPost, ts.URL+"/wp-login.php", + strings.NewReader("log=admin&pwd=hunter2")) + if err != nil { + t.Fatal(err) + } + post.Header.Set("Content-Type", "application/x-www-form-urlencoded") + post.Header.Set("CF-Connecting-IP", clientIP) + resp, err := http.DefaultClient.Do(post) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10) + if err != nil { + t.Fatal(err) + } + if len(recs) != 1 { + t.Fatalf("凭据记录数 = %d, want 1", len(recs)) + } + if recs[0].IP != clientIP { + t.Errorf("取证 IP = %q, want 精确地址 %q(不能写 /64 聚合键)", recs[0].IP, clientIP) + } + + if len(store.hits) != 1 { + t.Fatalf("落库记录数 = %d, want 1", len(store.hits)) + } + if store.hits[0].IP != clientIP { + t.Errorf("落库 IP = %q, want 精确地址 %q(元数据表同样是取证物)", store.hits[0].IP, clientIP) + } + + if !ab.IsBanned(banKey) { + t.Errorf("封禁键 %q 未生效:IPv6 必须按 /64 聚合", banKey) + } + if ab.IsBanned(clientIP) { + t.Errorf("精确地址 %q 不应单独成为封禁键:写入与判定必须是同一口径", clientIP) + } + + // 同 /64 内换个地址仍应被拦 —— 这才是按前缀聚合的实际价值。 + get, err := http.NewRequest(http.MethodGet, ts.URL+"/anything", nil) + if err != nil { + t.Fatal(err) + } + get.Header.Set("CF-Connecting-IP", "2408:8207:1234:5678::9999") + resp2, err := http.DefaultClient.Do(get) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusForbidden { + t.Errorf("同 /64 内换地址 status = %d, want 403", resp2.StatusCode) + } +} + +// captureStore 是 db.HoneypotStore 的极简实现:只把 Create 的记录留下, +// 其余方法返回零值。真库测试有 DB 门控、不适合放进这组结构性用例, +// 而「落库的 IP 是不是精确地址」又必须被钉住(混用聚合键正是曾经的缺陷)。 +type captureStore struct { + hits []*db.HoneypotHit +} + +func (c *captureStore) Create(_ context.Context, hit *db.HoneypotHit) error { + c.hits = append(c.hits, hit) + return nil +} + +func (c *captureStore) ListSince(context.Context, time.Time, int) ([]*db.HoneypotHit, error) { + return nil, nil +} + +func (c *captureStore) CountSince(context.Context, time.Time) (int64, error) { return 0, nil } + +func (c *captureStore) TopSources(context.Context, time.Time, int) ([]*db.HoneypotSourceStat, error) { + return nil, nil +} + +func (c *captureStore) TopPaths(context.Context, time.Time, int) ([]*db.HoneypotPathStat, error) { + return nil, nil +} + +func (c *captureStore) DeleteBefore(context.Context, time.Time) (int64, error) { return 0, nil } + +func (c *captureStore) DeleteOldestBeyond(context.Context, int) (int64, error) { return 0, nil } + +func (c *captureStore) Compact(context.Context) error { return nil } + +func (c *captureStore) Size(context.Context) (db.HoneypotSize, error) { + return db.HoneypotSize{}, nil +} + +func (c *captureStore) LegacyCredentialColumns(context.Context) ([]string, error) { + return nil, nil +} + +func (c *captureStore) PurgeLegacyCredentialColumns(context.Context) ([]string, error) { + return nil, nil +} diff --git a/internal/route/route.go b/internal/route/route.go index 3f649df..77748c1 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -105,6 +105,23 @@ func newBanner() *autoban.Banner { HoneypotEnabled: conf.Security.EnableHoneypot, HoneypotThreshold: conf.Security.HoneypotThreshold, HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute, + + // 建封日志:封禁表是纯内存、外部完全看不见,而线上出现过 + // 「邮件说封了、实际还能访问」—— 当时没有任何记录能说明封的是哪个键、 + // 到什么时候解除,排查只能靠猜。键与 IsBanned 的判断口径完全一致 + // (shared.LimitKey,IPv6 折成 /64),日志与判定不会各说一套。 + OnBan: func(key string, duration time.Duration, reason string) { + reasonText := "敏感路径探测" + if reason == autoban.ReasonHoneypot { + reasonText = "蜜罐提交凭据" + } + logrus.WithFields(logrus.Fields{ + "ban_key": key, + "reason": reasonText, + "duration": duration.String(), + "expires_at": time.Now().Add(duration).Format("2006-01-02 15:04:05"), + }).Warn("已自动封禁来源(内存态,重启进程即解除;键为聚合口径,IPv6 为 /64)") + }, }) } diff --git a/internal/security/autoban/autoban.go b/internal/security/autoban/autoban.go index 3e683c6..0da1d10 100644 --- a/internal/security/autoban/autoban.go +++ b/internal/security/autoban/autoban.go @@ -55,8 +55,27 @@ type Options struct { HoneypotThreshold int // HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。 HoneypotDuration time.Duration + + // OnBan 在「新建一条封禁」时回调,供上层打日志 / 埋点。 + // + // 存在的意义:封禁表是纯内存的,从外部完全看不见 —— 线上出现过 + // 「邮件说封了、实际没挡住」,而当时没有任何日志能说明封的是哪个键、 + // 到什么时候解除,排查只能靠猜。有了回调,每一次建封都会留下痕迹。 + // + // ⚠️ 只在真正新建封禁时触发:已在封禁期内再次命中不会重复回调 + // (与告警去重口径一致)。回调在独立 goroutine 中执行,不要假设顺序, + // 也不要在回调里调用本 Banner 的方法。 + OnBan func(key string, duration time.Duration, reason string) } +// 封禁原因(传给 Options.OnBan)。 +const ( + // ReasonProbe 常规通道:敏感路径 / 404 探测累计到阈值。 + ReasonProbe = "probe" + // ReasonHoneypot 蜜罐通道:往假登录页提交了凭据。 + ReasonHoneypot = "honeypot" +) + // sensitivePaths 常见后台/配置/漏洞探测路径特征(忽略大小写,完整段前缀匹配)。 // 注意:前缀匹配抓不到 //xxx/wp-includes/wlwmanifest.xml 这类"目录枚举变体"(敏感段 // 出现在路径中部),需要与 sensitiveFragments(任意位置子串)配合使用。 @@ -114,6 +133,8 @@ type Banner struct { honeypotEnabled bool honeypotThreshold int honeypotDuration time.Duration + + onBan func(key string, duration time.Duration, reason string) } // New 按配置创建封禁器并注册全局清理。 @@ -157,6 +178,7 @@ func New(opts Options) *Banner { honeypotEnabled: opts.HoneypotEnabled, honeypotThreshold: honeypotThreshold, honeypotDuration: honeypotDuration, + onBan: opts.OnBan, } register(b) return b @@ -214,6 +236,7 @@ func (b *Banner) RecordHoneypot(ip, path string) bool { return true } b.banned[ip] = now.Add(b.honeypotDuration) + b.notifyBan(ip, b.honeypotDuration, ReasonHoneypot) // 告警(每 IP 仅一次) if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] { @@ -285,6 +308,7 @@ func (b *Banner) Record(ip, path string, kind int) { } until := now.Add(b.duration) b.banned[ip] = until + b.notifyBan(ip, b.duration, ReasonProbe) // 告警(每 IP 仅一次) if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] { @@ -297,6 +321,18 @@ func (b *Banner) Record(ip, path string, kind int) { } } +// notifyBan 触发 OnBan 回调。 +// +// 调用点都在持锁区间内,因此这里只做「取函数 + 起 goroutine」: +// 回调本身可能在写日志、发邮件,放到锁外执行才不会拖慢被判定的请求。 +func (b *Banner) notifyBan(key string, duration time.Duration, reason string) { + fn := b.onBan + if fn == nil { + return + } + go fn(key, duration, reason) +} + // IsBanned 判断 IP 是否处于封禁期;到期自动解除。 // // 注意:即使 enabled(enable_auto_ban)关闭,蜜罐通道写入的封禁依然生效 —— diff --git a/internal/security/autoban/autoban_test.go b/internal/security/autoban/autoban_test.go index 490bf0c..e3212af 100644 --- a/internal/security/autoban/autoban_test.go +++ b/internal/security/autoban/autoban_test.go @@ -155,3 +155,69 @@ func TestIsSensitivePath(t *testing.T) { } } } + +// TestOnBanCallback 验证建封时会回调上层(键 / 时长 / 原因), +// 且「已在封禁期内重复命中」不会重复回调。 +// +// 存在的意义:封禁表是纯内存的,外部完全看不见。线上出现过 +// 「邮件说封了、实际还能访问」,而当时没有任何记录能说明封的是哪个键、 +// 到什么时候解除 —— 这个回调就是那条记录的唯一来源。 +func TestOnBanCallback(t *testing.T) { + type banEvent struct { + key string + duration time.Duration + reason string + } + events := make(chan banEvent, 4) + b := New(Options{ + Enabled: true, + ThresholdSensitive: 1, + TrackNotFound: false, + Window: time.Minute, + Duration: 10 * time.Minute, + HoneypotEnabled: true, + HoneypotThreshold: 1, + HoneypotDuration: 24 * time.Hour, + OnBan: func(key string, duration time.Duration, reason string) { + events <- banEvent{key: key, duration: duration, reason: reason} + }, + }) + + const key = "2408:8207:1234:5678::/64" + if !b.RecordHoneypot(key, "/wp-login.php") { + t.Fatal("首次蜜罐命中应触发封禁") + } + select { + case e := <-events: + if e.key != key { + t.Errorf("回调键 = %q, want %q", e.key, key) + } + if e.duration != 24*time.Hour { + t.Errorf("回调时长 = %v, want 24h(蜜罐应走 HoneypotDuration)", e.duration) + } + if e.reason != ReasonHoneypot { + t.Errorf("回调原因 = %q, want %q", e.reason, ReasonHoneypot) + } + case <-time.After(time.Second): + t.Fatal("蜜罐封禁没有触发 OnBan 回调") + } + + // 已在封禁期内再次命中:不新建封禁,也就不应重复回调。 + b.RecordHoneypot(key, "/wp-login.php") + select { + case e := <-events: + t.Fatalf("重复命中不应重复回调,收到 %+v", e) + case <-time.After(50 * time.Millisecond): + } + + // 常规通道(敏感路径)也要回调,且时长用常规 Duration、原因可区分。 + b.Record("1.2.3.4", "/admin", KindSensitive) + select { + case e := <-events: + if e.key != "1.2.3.4" || e.reason != ReasonProbe || e.duration != 10*time.Minute { + t.Errorf("常规封禁回调 = %+v, want key=1.2.3.4 reason=probe duration=10m", e) + } + case <-time.After(time.Second): + t.Fatal("常规封禁没有触发 OnBan 回调") + } +} diff --git a/internal/security/honeypot/credstore.go b/internal/security/honeypot/credstore.go index ecde6c3..d6d5665 100644 --- a/internal/security/honeypot/credstore.go +++ b/internal/security/honeypot/credstore.go @@ -28,7 +28,8 @@ import ( // // 数据库里只保留「发生过一次带凭据的提交」这个事实(HadFields 等元数据)。 type CredRecord struct { - Time time.Time `json:"time"` + Time time.Time `json:"time"` + // IP 是精确客户端地址;封禁用的 /64 聚合键不会写进来(见 db.HoneypotHit.IP)。 IP string `json:"ip"` Method string `json:"method"` Path string `json:"path"` diff --git a/templates/mail/honeypot-alert.html b/templates/mail/honeypot-alert.html index 3cb8f60..fd232b4 100644 --- a/templates/mail/honeypot-alert.html +++ b/templates/mail/honeypot-alert.html @@ -31,6 +31,9 @@
来源 IP:{{.ip}}
+ {{if ne .ip .ban_key}} +
封禁范围:{{.ban_key}}(IPv6 按 /64 前缀聚合,整段一起封)
+ {{end}}
网络归属:{{.provider}}
诱饵路径:{{.path}}
处置:{{.disposition}}
@@ -45,8 +48,8 @@
攻击者在假登录页上提交了凭据(假页面只返回静态内容,从不校验、也从不返回任何真实信息)。 - 完整记录(含 User-Agent、Referer、原始 body)已入库,可用 - TamaBox honeypot hits 命令查看。 + 完整记录(含 User-Agent、Referer、原始 body)已写入取证文件,可用 + TamaBox honeypot hits 命令查看;用户名与密码只存在该文件里,不写入数据库。