Files
tamabox/internal/cmd/web.go
T
tamakyi c29e1d071f fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库
一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。

二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。

三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
  实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
  可稳定触发)。统一改 rune 截断并按列宽逐项对齐。

四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
  同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
  GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
  不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
  补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。

五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
  目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
  既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
  honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。

验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
2026-10-21 15:43:25 +08:00

128 lines
4.2 KiB
Go

// Copyright 2022 E99p1ant. 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 (
"fmt"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/uptrace/opentelemetry-go-extra/otellogrus"
"github.com/uptrace/uptrace-go/uptrace"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/route"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/tracing"
)
var Web = &cli.Command{
Name: "web",
Usage: "Start web server",
Action: runWeb,
}
func runWeb(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
// 解析可信代理网段(用于客户端 IP 解析,避免维护 Cloudflare 庞大 CIDR 清单)。
if err := shared.InitTrustedProxies(); err != nil {
return errors.Wrap(err, "init trusted proxies")
}
if conf.App.UptraceDSN != "" {
uptrace.ConfigureOpentelemetry(
uptrace.WithDSN(conf.App.UptraceDSN),
uptrace.WithServiceName("tamabox"),
uptrace.WithServiceVersion(conf.BuildCommit),
)
logrus.WithContext(ctx.Context).Debug("Tracing enabled.")
}
logrus.AddHook(otellogrus.NewHook(otellogrus.WithLevels(
logrus.PanicLevel,
logrus.FatalLevel,
logrus.ErrorLevel,
logrus.WarnLevel,
)))
dbType := conf.Database.Type
var dsn string
switch dbType {
case "mysql", "":
dsn = conf.MySQLDsn()
case "postgres":
dsn = conf.PostgresDsn()
default:
return errors.Errorf("unknown database type: %q", dbType)
}
conf.Database.DSN = dsn
_, err := db.Init(dbType, dsn)
if err != nil {
return errors.Wrap(err, "connect to database")
}
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
//
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
stopRetention := make(chan struct{})
if conf.Security.HoneypotAutoClean {
var credPurge db.CredPurgeable
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
logrus.WithError(err).Warn("蜜罐凭据目录不可用,凭据文件将不会被自动清理")
} else {
credPurge = cs
}
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
defer close(stopRetention)
}
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
shared.App.SetLang(conf.App.DefaultLang)
r := route.New()
r.Use(tracing.Middleware("TamaBox"))
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
// - ReadHeaderTimeout 防止慢速客户端占用连接
// - ReadTimeout / WriteTimeout 防止读写挂死
// - IdleTimeout 复用 keep-alive 连接
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
addr := conf.Server.Listen
if addr == "" {
addr = fmt.Sprintf(":%d", conf.Server.Port)
}
server := &http.Server{
Addr: addr,
Handler: route.GzipHandler(r),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return errors.Wrap(err, "start http server")
}
return nil
}