feat(autoban): 封禁响应图改为 ban_image_path 配置,默认仍是纯文字

上一版把 403 图 //go:embed 进了二进制。实际部署里图会集中放在 R2 统一管理、
由运维分发到各机器,进仓库反而成了负担:换一张图就要重新编译。

改为配置驱动:
- 新增 [security] ban_image_path(相对路径以进程工作目录为基准);
- 留空 / 文件读不到 / 是目录 / 不是图片 / 超过 2 MB → 一律回退为纯文字
  "403 Forbidden",即改造前的老行为。封禁响应是最后一道兜底,不能因为
  一张图缺失就变成 500,更不能把站点拦在启动阶段(失败只 Warn);
- 启动时读一次装进 atomic.Pointer,热路径零磁盘 IO;换图 = 换文件 + 重启。

安全细节:按内容嗅探(http.DetectContentType)而不是扩展名,必须是 image/*。
把 ban_image_path 配成 app.ini / .env 会被拒绝并告警 —— 否则等于把配置文件
原样吐给每一个被封的攻击者。这条有专门的用例与变异验证。

顺带删掉 internal/route/assets(webp + embed),已无引用。

测试:
- TestWriteBannedTextFallback:未配图 → 403 + text/plain + 13 字节明文;
- TestWriteBannedServesConfiguredImage:配了 1×1 PNG → image/png(按内容嗅探)、
  Content-Length 与文件一致、body 与文件逐字节相同;
- TestLoadBanImageRejectsBadInput:不存在 / 是目录 / 非图片 / 空文件四种配置错误
  都不得装载,且**不得把文件内容当响应体吐出去**;
- TestLoadBanImageTooLarge:超 2 MB 拒绝;
- TestWriteBannedHead:两条分支的 HEAD 都只回头不给 body。
变异验证:去掉 image/* 校验 → 用例报「把文件内容当响应体吐了出去:
"[server]\nsalt = secret"」;强制走纯文字分支 → 配图用例按预期变红。

实测:真 webp 嗅探为 image/webp;app.sample.ini 嗅探为 text/plain 被拒。
This commit is contained in:
tamakyi
2026-10-23 15:25:07 +08:00
parent 6fad0cbe50
commit 86ea96e5ca
10 changed files with 298 additions and 70 deletions
+26
View File
@@ -179,3 +179,29 @@ Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着
- 换图方式:覆盖同目录 403.webp → 重新编译,无配置项。
- 体积代价:每封禁请求 8.6 KB(原 13 字节)。被封来源重试频繁时要留意带宽,
已压到 256×256;别塞几百 KB 原图。
### 关于「403 图放 R2 是否更好」的结论
结论:**保持 embed**。关键事实是「服务端代理/重定向并不省我们带宽」——
字节最终还是从本机发给被封客户端;想真省只能让客户端自己去 R2 取,
那就要放弃「403 直接是图片」,改成 403 + 一个 `<img src=外链>` 的 HTML。
而本项目 R2 目前只有预签名 URL(7 天过期),长期用必须开 r2.dev 或绑自定义域
(docs/theme-new-sakura.md 已记:直连 `*.r2.cloudflarestorage.com` 会 403)。
8.6 KB 的单张图不值得引入外部依赖;被 ban 的多是脚本,根本不会加载图片。
若动机只是「换图不想重编译」,加 `[security] ban_image_path` 磁盘覆盖更划算。
### 改为 ban_image_path(配置驱动,默认纯文字)
按用户要求撤掉 embed:`internal/route/assets/`(含 403.webp)已删除,
行为改为「配了合法图 → 回图;留空 / 读不到 / 不是图 / >2MB → 纯文字 403」。
- `[security] ban_image_path`(相对路径以进程工作目录为基准);
- `loadBanImage()` 在 `route.New()` 里启动时读一次 → `atomic.Pointer[banResponse]`,
热路径零磁盘 IO;任何失败只 Warn 不改启动(封禁响应是最后一道兜底,不能变 500);
- **只认内容不认扩展名**(`http.DetectContentType` 必须是 `image/*`):配成
app.ini/.env 会被拒绝,否则等于把配置文件吐给攻击者(变异验证过这条);
- 上限 2 MB(被封来源反复重试,大图放大出网带宽);`Content-Length` 显式给;
HEAD 不写 body;`no-store`。
- 实测 sniff:真 webp → `image/webp`;`app.sample.ini` → `text/plain`(被拒)。
**教训**:同一功能改了两版(先 embed 后配置化),第二版要把上一版的资源一起清掉,
否则留一个没人引用的 `assets` 包。`go vet ./...` 不报、测试也不报(该包仍被旧测试引用时)。
+8
View File
@@ -153,6 +153,14 @@ honeypot_ban_minutes = 1440
; 建议单独挂一个卷,并让它不参与备份。
honeypot_cred_dir = data/honeypot-creds
; 被封禁(自动封禁 / 蜜罐封禁)时返回的图片路径,相对路径以进程工作目录为基准。
; 留空、文件读不到、或内容不是图片 → 一律回退为纯文字 "403 Forbidden"。
; 只接受 image/* 的内容(png / jpeg / gif / webp):指向 app.ini 之类会被拒绝,
; 否则等于把配置文件当响应体吐给攻击者。
; 建议用 256×256 左右的图并压到 20 KB 以内 —— 被封的来源会反复重试,
; 每张图都会照常占用出网带宽。
ban_image_path =
; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
default_moderation_strictness =
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
+20 -4
View File
@@ -112,19 +112,35 @@ honeypot_cred_dir = data/honeypot-creds
### 被封之后会看到什么
状态码 `403`,body 是一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),不再是那行 13 字节的明文:
状态码永远 `403`,body 由 `[security] ban_image_path` 决定:
```ini
[security]
ban_image_path = data/403.webp
```
| 情况 | 响应体 |
|---|---|
| 配了合法图片 | 图片本身(`Content-Type` 按内容嗅探,png/jpeg/gif/webp 都行) |
| 留空 | 纯文字 `403 Forbidden`(改造前的老行为) |
| 文件读不到 / 是目录 / 不是图片 / 超过 2 MB | 纯文字 `403 Forbidden` + 启动日志 Warn |
```
$ curl -si https://你的站点/anything | head -4
$ curl -si https://你的站点/anything | head -5
HTTP/2 403
content-type: image/webp
content-length: 8608
cache-control: no-store, no-cache, must-revalidate
```
这样做有两个目的:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。
为什么要能换成图:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。但这是**可选**的——配错了不会影响封禁本身,只是回到纯文字。
图片用 `go:embed` 编进二进制(`internal/route/assets/403.webp`),不占任何可公开访问的 URL。想换成自己的图:覆盖同目录文件后重新编译、重启即可。注意体积——被封的来源会反复重试,8.6 KB 是刻意压过的(原图别直接塞几百 KB 的)。
几个约束,都是刻意的:
- **只认内容,不认扩展名**(`http.DetectContentType`)。把 `ban_image_path` 配成 `app.ini` / `.env` 会被拒绝并告警——否则等于把配置文件原样吐给每一个被封的攻击者。
- **上限 2 MB**。被封来源会反复重试,每张图都照常占用出网带宽;建议 256×256、20 KB 以内。
- **启动时读一次**,之后每个请求只读内存,不在热路径上做磁盘 IO。换图 = 换文件 + 重启进程。
- `no-store`:中间层缓存一张 403 没有意义,还会让解封后的响应继续命中缓存。
### 封的是一把「键」,不是一个人
+9 -1
View File
@@ -125,7 +125,15 @@ var (
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
// 目录会被强制为 0700、文件 0600;留空使用默认 data/honeypot-creds。
// 目录不可写时只降级为「不保存凭据」,不会影响站点启动。
HoneypotCredDir string `ini:"honeypot_cred_dir"`
HoneypotCredDir string `ini:"honeypot_cred_dir"`
// BanImagePath 被封禁时返回的图片路径(本地文件,相对路径以进程工作目录为基准)。
//
// 留空、文件读不到、或内容不是图片时,一律回退为纯文字 "403 Forbidden" ——
// 这条路径绝不能因为一张图把「拒绝」响应弄坏,更不能让缺失的资源变成 500。
//
// 只接受 image/* 的内容:如果配成 app.ini 之类的文件会被拒绝并告警,
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。
BanImagePath string `ini:"ban_image_path"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

-20
View File
@@ -1,20 +0,0 @@
// Package assets 存放「必须由服务端直接吐出」的二进制资源。
//
// 为什么不放 static/:static 下的东西由静态文件中间件按 URL 提供,
// 而这里的图片只该出现在 403 响应体里 —— 不该有一个能直接访问的公开路径
// (否则被 ban 的人可以先抓一张「解禁图」,也等于多一个指纹入口)。
package assets
import _ "embed"
// BannedImage 是被封禁来源访问任何路径时返回的图(256×256 webp,约 8.6 KB)。
//
// 换成图片的动机:被封之后每次请求都只拿到 13 字节的 "403 Forbidden",
// 既没有信息量,也让人误以为是站点挂了。这张图明确写着 403 Forbidden,
// 人一看就懂,而状态码仍然是 403 —— 语义没变,只换了 body。
//
// 想替换:直接覆盖同目录的 403.webp 后重新编译(go:embed 是编译期打包的,
// 不需要重启之外的操作)。
//
//go:embed 403.webp
var BannedImage []byte
+104 -14
View File
@@ -2,33 +2,123 @@ package route
import (
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/tamakyi/TamaBox/internal/route/assets"
"github.com/sirupsen/logrus"
)
// banText 是没有任何封禁图时的响应体 —— 改造前的老行为。
const banText = "403 Forbidden"
// maxBanImageBytes 限制封禁图的大小。
//
// 不是为了防攻击者(路径来自运维配置),而是防手滑:被封的来源会反复重试,
// 每张图都会照常占用出网带宽,几百 KB 的原图能把这条路径变成放大器。
const maxBanImageBytes = 2 << 20
// banResponse 是装载好的封禁响应(图片体 + 内容类型)。
type banResponse struct {
body []byte
contentType string
}
// bannedImage 在启动时装载一次,之后每个被封请求只读。
// nil 表示没有可用的图,回退纯文字。
var bannedImage atomic.Pointer[banResponse]
// loadBanImage 读取 [security] ban_image_path 指向的图片。
//
// 任何失败都不返回 error、不影响启动:封禁响应是最后一道兜底,
// 不能因为一张图缺失就变成 500,更不能把站点拦在启动阶段。
// 失败时 bannedImage 保持 nil,writeBanned 会回退成纯文字 "403 Forbidden"。
func loadBanImage(path string) bool {
// 先清空:重复调用(测试、将来的热替换)时不至于留着上一次的图。
bannedImage.Store(nil)
path = strings.TrimSpace(path)
if path == "" {
return false
}
info, err := os.Stat(path)
if err != nil {
logrus.WithError(err).WithField("path", path).
Warn("ban_image_path 不可读,封禁响应回退为纯文字 403")
return false
}
if info.IsDir() {
logrus.WithField("path", path).
Warn("ban_image_path 是个目录,封禁响应回退为纯文字 403")
return false
}
if info.Size() > maxBanImageBytes {
logrus.WithFields(logrus.Fields{"path": path, "size": info.Size(), "max": maxBanImageBytes}).
Warn("ban_image_path 超过 2 MB(被封来源会反复重试,大图等于放大出网带宽),已回退为纯文字 403")
return false
}
body, err := os.ReadFile(path)
if err != nil {
logrus.WithError(err).WithField("path", path).
Warn("ban_image_path 读取失败,封禁响应回退为纯文字 403")
return false
}
// 按内容嗅探而不是按扩展名:配成 app.ini / .env 这类文件时必须拒绝,
// 否则等于把配置文件原样吐给每一个被封的攻击者。
// DetectContentType 只看前 512 字节,对常见图片格式足够。
ct := http.DetectContentType(body)
if !strings.HasPrefix(ct, "image/") {
logrus.WithFields(logrus.Fields{"path": path, "detected": ct}).
Warn("ban_image_path 不是图片(只接受 image/*:png/jpeg/gif/webp),已回退为纯文字 403")
return false
}
bannedImage.Store(&banResponse{body: body, contentType: ct})
logrus.WithFields(logrus.Fields{"path": path, "type": ct, "size": len(body)}).
Info("封禁响应图已装载")
return true
}
// writeBanned 写出「来源已被封禁」的响应。
//
// 状态码仍然是 403 —— 语义不变,只把 body 从 13 字节的 "403 Forbidden"
// 换成一张 256×256 的 webp(约 8.6 KB):
// - 顶掉明文:爆破/扫描脚本不少会读响应体里的 "403 Forbidden" 来确认自己
// 已被拦,换掉 body 至少让这类判断失效(状态码本身还是照常返回);
// - 给人看:误封(比如共享出口、代理)时打开浏览器看到一张写着 403 的图,
// 比一个没有任何样式的白屏好判断得多。
// 状态码始终是 403,只决定 body 是什么:
// - 配了合法图片 → 直接回图片(顶掉明文,爆破脚本靠读 "403 Forbidden"
// 确认自己被拦的那套判断会失效;人肉访问时也能看懂发生了什么);
// - 没配 / 配错 → 纯文字 "403 Forbidden",即改造前的老行为。
//
// 图片是编译期 embed 的,不走磁盘 IO,也不占一个可公开访问的 URL。
// 被封住的来源往往反复重试,所以这里显式给 Content-Length(否则 8 KB 会退回
// chunked),并标 no-store:中间层缓存一张 403 图没有意义,还会让解封后
// 的响应继续命中缓存。
// 两条分支都显式给 Content-Length:图片动辄几十 KB,超过 Go 的 2 KB 写缓冲,
// 不设就会退回 chunked。no-store 是因为中间层缓存一张 403 没有意义,
// 还会让解封后的响应继续命中缓存。
func writeBanned(w http.ResponseWriter, method string) {
img := bannedImage.Load()
if img == nil {
writeBannedText(w, method)
return
}
h := w.Header()
h.Set("Content-Type", "image/webp")
h.Set("Content-Length", strconv.Itoa(len(assets.BannedImage)))
h.Set("Content-Type", img.contentType)
h.Set("Content-Length", strconv.Itoa(len(img.body)))
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
h.Set("X-Robots-Tag", "noindex, nofollow")
w.WriteHeader(http.StatusForbidden)
// HEAD 不写 body(net/http 也会丢弃,这里省掉一次无意义的拷贝)。
if method != http.MethodHead {
_, _ = w.Write(assets.BannedImage)
_, _ = w.Write(img.body)
}
}
func writeBannedText(w http.ResponseWriter, method string) {
h := w.Header()
h.Set("Content-Type", "text/plain; charset=utf-8")
h.Set("Content-Length", strconv.Itoa(len(banText)))
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.WriteHeader(http.StatusForbidden)
if method != http.MethodHead {
_, _ = w.Write([]byte(banText))
}
}
+120 -26
View File
@@ -1,55 +1,149 @@
package route
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/tamakyi/TamaBox/internal/route/assets"
)
// TestWriteBanned 守住「被封禁后的响应是一张图」。
func TestWriteBanned(t *testing.T) {
// tinyPNG 是 1×1 的合法 PNG,用来验证「配了图就回图」这条分支。
const tinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
func writeTempImage(t *testing.T, data []byte) string {
t.Helper()
p := filepath.Join(t.TempDir(), "ban.png")
if err := os.WriteFile(p, data, 0o600); err != nil {
t.Fatal(err)
}
return p
}
func decodeTinyPNG(t *testing.T) []byte {
t.Helper()
b, err := base64.StdEncoding.DecodeString(tinyPNG)
if err != nil {
t.Fatal(err)
}
return b
}
// TestWriteBannedTextFallback 守住默认行为:没配图时就是改造前的纯文字 403。
func TestWriteBannedTextFallback(t *testing.T) {
if loadBanImage("") {
t.Fatal("空路径不应装载出图")
}
rec := httptest.NewRecorder()
writeBanned(rec, http.MethodGet)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403(换成图片不改变状态码语义)", rec.Code)
t.Fatalf("status = %d, want 403", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
t.Errorf("Content-Type = %q, want image/webp", ct)
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("Content-Type = %q, want text/plain(没配图时回退纯文字)", ct)
}
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(assets.BannedImage)) {
t.Errorf("Content-Length = %q, want %d", cl, len(assets.BannedImage))
if body := rec.Body.String(); body != banText {
t.Errorf("body = %q, want %q", body, banText)
}
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(banText)) {
t.Errorf("Content-Length = %q, want %d", cl, len(banText))
}
}
// TestWriteBannedServesConfiguredImage 守住「配了图就回图」。
func TestWriteBannedServesConfiguredImage(t *testing.T) {
data := decodeTinyPNG(t)
if !loadBanImage(writeTempImage(t, data)) {
t.Fatal("合法的 PNG 应装载成功")
}
t.Cleanup(func() { loadBanImage("") })
rec := httptest.NewRecorder()
writeBanned(rec, http.MethodGet)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403(换 body 不改变状态码语义)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("Content-Type = %q, want image/png(按内容嗅探,不看扩展名)", ct)
}
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(data)) {
t.Errorf("Content-Length = %q, want %d", cl, len(data))
}
if body := rec.Body.Bytes(); string(body) != string(data) {
t.Errorf("body 与文件内容不一致(%d vs %d 字节)", len(body), len(data))
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q, want 含 no-store(中间层缓存 403 无意义)", cc)
t.Errorf("Cache-Control = %q, want 含 no-store", cc)
}
}
body := rec.Body.Bytes()
// webp 的容器头:RIFF....WEBP。写成字节判断,避免「图片被换成 HTML」这类回归。
if len(body) < 12 || string(body[0:4]) != "RIFF" || string(body[8:12]) != "WEBP" {
t.Errorf("body 不是 webp(前 12 字节 %q)", body[:min(12, len(body))])
// TestLoadBanImageRejectsBadInput 覆盖各种配置错误:都必须降级为纯文字,
// 而不是报错、更不是把文件内容原样吐出去。
func TestLoadBanImageRejectsBadInput(t *testing.T) {
t.Cleanup(func() { loadBanImage("") })
cases := []struct {
name string
path string
}{
{"不存在的文件", filepath.Join(t.TempDir(), "nope.png")},
{"是个目录", t.TempDir()},
{"不是图片", writeTempImage(t, []byte("[server]\nsalt = secret\n"))},
{"空文件", writeTempImage(t, nil)},
}
if strings.Contains(string(body), "403 Forbidden") && len(body) < 512 {
t.Error("body 仍是明文文案,没换成图片")
for _, c := range cases {
if loadBanImage(c.path) {
t.Errorf("%s: 不应装载成功", c.name)
}
rec := httptest.NewRecorder()
writeBanned(rec, http.MethodGet)
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("%s: 应回退纯文字,实际 Content-Type = %q", c.name, ct)
}
if body := rec.Body.String(); strings.Contains(body, "salt") {
t.Errorf("%s: 把文件内容当响应体吐了出去:%q", c.name, body)
}
}
}
// TestLoadBanImageTooLarge 守住体积上限:被封来源反复重试,大图等于放大出网带宽。
func TestLoadBanImageTooLarge(t *testing.T) {
big := make([]byte, maxBanImageBytes+1)
copy(big, []byte("\x89PNG\r\n\x1a\n"))
if loadBanImage(writeTempImage(t, big)) {
t.Error("超过 2 MB 的文件不应被装载")
}
loadBanImage("")
}
// TestWriteBannedHead 覆盖 HEAD:头照给(含 Content-Length),body 不发。
func TestWriteBannedHead(t *testing.T) {
rec := httptest.NewRecorder()
writeBanned(rec, http.MethodHead)
for _, withImage := range []bool{false, true} {
if withImage {
data := decodeTinyPNG(t)
if !loadBanImage(writeTempImage(t, data)) {
t.Fatal("装载失败")
}
} else {
loadBanImage("")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("HEAD 不应有 body,实际 %d 字节", rec.Body.Len())
}
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(assets.BannedImage)) {
t.Errorf("Content-Length = %q, want %d", cl, len(assets.BannedImage))
rec := httptest.NewRecorder()
writeBanned(rec, http.MethodHead)
if rec.Code != http.StatusForbidden {
t.Errorf("withImage=%v: status = %d, want 403", withImage, rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("withImage=%v: HEAD 不应有 body,实际 %d 字节", withImage, rec.Body.Len())
}
if rec.Header().Get("Content-Length") == "" {
t.Errorf("withImage=%v: HEAD 也应给出 Content-Length", withImage)
}
}
loadBanImage("")
}
+6 -5
View File
@@ -297,12 +297,13 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want 403(蜜罐封禁不得被 enable_auto_ban 连坐)", resp2.StatusCode)
}
// body 是一张 403 图(见 writeBanned),不再是那行明文。
if ct := resp2.Header.Get("Content-Type"); ct != "image/webp" {
t.Errorf("Content-Type = %q, want image/webp", ct)
// 没配 ban_image_path 时,封禁响应就是纯文字(见 writeBanned)。
// 配了图的分支由 banned_test.go 覆盖。
if ct := resp2.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("Content-Type = %q, want text/plain", ct)
}
if len(body) < 1024 {
t.Errorf("body 只有 %d 字节,want 一张图片", len(body))
if !strings.Contains(string(body), banText) {
t.Errorf("body = %q, want 含 %q", truncate(string(body), 80), banText)
}
}
+5
View File
@@ -136,6 +136,11 @@ func New() *flamego.Flame {
// 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。
ab := newBanner()
// 封禁响应图:启动时读一次,之后每个被封请求只读内存。
// 留空 / 读不到 / 不是图片 → 回退纯文字 403(见 loadBanImage),
// 绝不因为一张图把站点拦在启动阶段。
loadBanImage(conf.Security.BanImagePath)
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
hp := honeypot.New(honeypot.Options{