diff --git a/go.mod b/go.mod index 93673f6..84aca24 100644 --- a/go.mod +++ b/go.mod @@ -125,7 +125,7 @@ require ( github.com/projectdiscovery/blackrock v0.0.2 // indirect github.com/projectdiscovery/retryabledns v1.0.115 // indirect github.com/projectdiscovery/utils v0.11.1 // indirect - github.com/redis/go-redis/v9 v9.5.1 // indirect + github.com/redis/go-redis/v9 v9.5.1 github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/internal/cmd/web.go b/internal/cmd/web.go index fb76ef3..127c6e6 100644 --- a/internal/cmd/web.go +++ b/internal/cmd/web.go @@ -5,8 +5,12 @@ package cmd import ( + "context" "fmt" "net/http" + "os" + "os/signal" + "syscall" "time" "github.com/pkg/errors" @@ -70,10 +74,18 @@ func runWeb(ctx *cli.Context) error { } conf.Database.DSN = dsn - _, err := db.Init(dbType, dsn) + gdb, err := db.Init(dbType, dsn) if err != nil { return errors.Wrap(err, "connect to database") } + // 退出前把连接池关掉,避免优雅关闭期间连接还在池里挂着。 + if sqlDB, dbErr := gdb.DB(); dbErr == nil { + defer func() { + if err := sqlDB.Close(); err != nil { + logrus.WithError(err).Warn("关闭数据库连接池失败") + } + }() + } // 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由 // 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。 @@ -94,6 +106,20 @@ func runWeb(ctx *cli.Context) error { defer close(stopRetention) } + // Redis 配了就必须连得上:session 建在它上面,宕机等于全站 500(含匿名访客)。 + // 启动期 fail fast,别让运维在「站点全 500」的现场去猜是不是 Redis。 + if conf.Redis.Addr != "" { + if err := shared.PingRedis(conf.Redis.Addr, conf.Redis.Password); err != nil { + return errors.Wrapf(err, + "无法连接 Redis(%s)。session 依赖它,连不上会导致全站 500,启动已中止。"+ + "请检查 [redis] addr/password,或清空 addr 改用进程内 session 与缓存", + conf.Redis.Addr) + } + logrus.WithField("addr", conf.Redis.Addr).Info("Redis 连接正常") + } else { + logrus.Info("未配置 [redis] addr:session 与缓存均使用进程内实现(单副本部署可用)") + } + logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server") // 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN) @@ -142,8 +168,43 @@ func runWeb(ctx *cli.Context) error { } 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") + + // 优雅关闭:收到 SIGTERM / SIGINT 后停止接受新连接,给在途请求最多 15 秒收尾。 + // + // 改动前是直接 ListenAndServe:Go 对 SIGTERM 的处理就是立即退出进程, + // 于是每次部署 / 重启都会把正在写库的 POST 就地处死,留下 + // 「提问已 INSERT、图片还没传完」「回答已写入、配额还没扣」这类半成品。 + // systemd Restart=always 与 docker restart 都救不了这个。 + runCtx, stop := signal.NotifyContext(ctx.Context, syscall.SIGTERM, os.Interrupt) + defer stop() + + serveErr := make(chan error, 1) + go func() { + err := server.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + // 正常的 Shutdown 路径,不算失败 + err = nil + } + serveErr <- err + }() + + select { + case err := <-serveErr: + // 端口被占 / 监听失败:没有在途请求要收尾,直接返回。 + if err != nil { + return errors.Wrap(err, "start http server") + } + return nil + case <-runCtx.Done(): + logrus.Info("收到退出信号,正在优雅关闭(最多 15 秒)") } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logrus.WithError(err).Warn("优雅关闭超时,强制断开剩余连接") + _ = server.Close() + } + logrus.Info("HTTP 服务已关闭") return nil } diff --git a/internal/route/accesslog.go b/internal/route/accesslog.go new file mode 100644 index 0000000..d244fc6 --- /dev/null +++ b/internal/route/accesslog.go @@ -0,0 +1,74 @@ +// 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 ( + "net/http" + "strings" + "time" + + "github.com/flamego/flamego" + "github.com/sirupsen/logrus" + + "github.com/tamakyi/TamaBox/internal/shared" +) + +// accessLogMiddleware 是 flamego.Classic() 内置 Logger() 的替代实现。 +// +// 为什么不能用内置的:它记录的是 ctx.Request().RequestURI —— **含 query string** +// (flamego@v1.9.4/logger.go:36,45)。本站有几个参数绝对不能进日志: +// +// ?t= 私密提问的访问钥匙(route/question/question.go) +// ?code= 一次性改密凭证(route/auth/recover.go) +// ?q= 探索页搜索词 +// +// 拿到 ?code= 可以直接重置该用户密码;拿到 ?t= 可以读别人发给箱主的私密提问。 +// 而日志通常落 journald / out.log / 容器 json-file,凡是能看日志的人 +// (外包运维、日志采集平台、Uptime 面板)都能直接利用。 +// +// internal/tracing/flamego.go:36-40 早就为同一原因只取 URL.Path 并留了注释, +// 这里是同一个判断,之前只是漏在了框架自带的那层中间件上。 +// +// 因此这里**只记 URL.Path**。日后若要按 query 排查,请显式加白名单键 +// (tab/sort/page 这类无隐私含义的),不要把 RawQuery 整体加回来。 +func accessLogMiddleware() flamego.Handler { + return func(ctx flamego.Context) { + started := time.Now() + // 放在 Recovery 外层:panic 被 Recovery 兜成 500 后,这里仍能记录状态码。 + ctx.Next() + + status := ctx.ResponseWriter().Status() + if !shouldAccessLog(ctx.Request().URL.Path, status) { + return + } + logrus.WithFields(accessLogFields(ctx.Request().Request, status, time.Since(started))).Info("access") + } +} + +// shouldAccessLog 判断这条请求要不要进访问日志。 +// +// 静态资源一页几十个请求,成功的不记,否则日志会被淹掉; +// 4xx/5xx 仍然要记 —— 排查「主题图 / 字体 404」靠的就是这些。 +func shouldAccessLog(path string, status int) bool { + return !(strings.HasPrefix(path, "/static/") && status < http.StatusBadRequest) +} + +// accessLogFields 构造访问日志字段。 +// +// 抽成独立函数而不是内联,是为了让「无论请求带什么 query,path 字段都只有 path」 +// 这件事能被单测直接断言 —— 这正是之前出问题的地方(框架默认 Logger 记的是 RequestURI)。 +func accessLogFields(req *http.Request, status int, elapsed time.Duration) logrus.Fields { + return logrus.Fields{ + "method": req.Method, + // 只取 URL.Path。绝不要换成 RequestURI / RawQuery,见文件头注释。 + "path": req.URL.Path, + "status": status, + "duration": elapsed.Round(time.Millisecond).String(), + // 客户端 IP 走站内唯一口径 shared.ResolveClientIP(反代下取真实来源, + // 且只在 TCP 对端可信时才采信代理头),与封禁记录用同一套值, + // 出事时能把「日志里这个人」和「ban_records 里这条」对上。 + "remote_ip": shared.ResolveClientIP(req), + } +} diff --git a/internal/route/accesslog_test.go b/internal/route/accesslog_test.go new file mode 100644 index 0000000..c800ae7 --- /dev/null +++ b/internal/route/accesslog_test.go @@ -0,0 +1,67 @@ +// 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 ( + "fmt" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestAccessLogNeverRecordsQuery 是本轮修复的回归测试。 +// +// 修复前用的是 flamego.Classic(),它自带的 Logger() 记录 RequestURI(含 query), +// 于是 ?t=<私密题钥匙> 与 ?code=<改密凭证> 原样进了日志(本地实测: +// `path=/_/tblocal/4?t=SECRETTOKEN123`)。拿到这些值可以直接读私密提问或重置密码。 +// +// 现在要求:不论请求带什么 query,日志里只有 URL.Path。 +func TestAccessLogNeverRecordsQuery(t *testing.T) { + cases := []struct { + name string + target string + want string + }{ + {"私密提问 token", "/_/alice/42?t=SECRETTOKEN123", "/_/alice/42"}, + {"改密凭证", "/recover/reset?code=SECRETCODE456", "/recover/reset"}, + {"探索页搜索词", "/explore?q=%E7%A7%98%E5%AF%86&sort=latest", "/explore"}, + {"分页参数", "/explore?page=2", "/explore"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest("GET", c.target, nil) + fields := accessLogFields(req, 200, 3*time.Millisecond) + + got, ok := fields["path"].(string) + require.True(t, ok, "path 字段必须是字符串") + require.Equal(t, c.want, got) + require.NotContains(t, got, "?", "path 里不允许出现 query 分隔符") + + // 兜底:整个字段集合里都不许出现被拦截的凭证 + for k, v := range fields { + require.NotContains(t, fmt.Sprint(v), "SECRET", + "字段 %q 泄露了 query 内容:%v", k, v) + } + }) + } +} + +// TestAccessLogSkipsStaticAssets 保证静态资源的成功请求不进日志(噪音控制), +// 但出错时仍然要记 —— 主题图 / 字体 404 正是靠这条排查的。 +func TestAccessLogSkipsStaticAssets(t *testing.T) { + require.False(t, shouldAccessLog("/static/css/base.css", 200)) + require.False(t, shouldAccessLog("/static/css/base.css", 304)) + require.True(t, shouldAccessLog("/static/css/base.css", 404), + "静态资源 404 必须记录,否则排查不了图裂") + require.True(t, shouldAccessLog("/static/css/base.css", 500)) + + // 业务路径一律记录 + require.True(t, shouldAccessLog("/", 200)) + require.True(t, shouldAccessLog("/_/alice", 302)) + require.True(t, shouldAccessLog("/staticish", 200), "只有 /static/ 前缀才算静态资源") +} diff --git a/internal/route/route.go b/internal/route/route.go index 55238c6..eed8192 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -184,7 +184,17 @@ func newBanner() *autoban.Banner { } func New() *flamego.Flame { - f := flamego.Classic() + // 不用 flamego.Classic():它内部注册的 Logger() 记录 RequestURI(含 query), + // 会把 ?t=<私密题钥匙> 与 ?code=<改密凭证> 明文写进日志(见 accesslog.go)。 + // Classic 提供的三件套在这里逐一显式装配: + // accessLogMiddleware() 替代 Logger(),只记 URL.Path + // flamego.Recovery() 行为不变 + // flamego.Static() 已在下方按 /static 前缀显式注册(原来就重复注册过) + f := flamego.New() + // 访问日志放在 Recovery 外层:panic 被兜成 500 后仍能记到状态码。 + f.Use(accessLogMiddleware()) + f.Use(flamego.Recovery()) + if conf.App.Production { flamego.SetEnv(flamego.EnvTypeProd) } @@ -281,6 +291,30 @@ func New() *flamego.Flame { // 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。 f.Use(requestBodyLimitMiddleware()) + // 缓存层:配了 Redis 用 Redis,没配就用进程内内存缓存。 + // + // 原来无条件走 cacheRedis,而 go-redis 对空 Addr 会回退到 localhost:6379 + // (go-redis/v9/options.go)。于是「不配 Redis」这种被配置允许的部署形态下, + // 每个依赖缓存的功能都在连一个不存在(或不属于本站)的本地实例: + // - route/auth/recover.go 读不回 code → 找回密码整体不可用,只看到「内部错误」 + // - chat_handler 的邮件每小时节流失效 → 每条私信都发一封邮件 + // 而这一切在启动期没有任何提示,排查时也看不出跟 Redis 有关。 + var cacheHandler flamego.Handler + if conf.Redis.Addr != "" { + cacheHandler = cache.Cacher(cache.Options{ + Initer: cacheRedis.Initer(), + Config: cacheRedis.Config{ + Options: &cacheRedis.Options{ + Addr: conf.Redis.Addr, + Password: conf.Redis.Password, + DB: 0, + }, + }, + }) + } else { + cacheHandler = cache.Cacher(cache.Options{Initer: cache.MemoryIniter()}) + } + reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true}) reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true}) @@ -524,16 +558,7 @@ func New() *flamego.Flame { f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI) }, context.APIEndpoint) }, - cache.Cacher(cache.Options{ - Initer: cacheRedis.Initer(), - Config: cacheRedis.Config{ - Options: &cacheRedis.Options{ - Addr: conf.Redis.Addr, - Password: conf.Redis.Password, - DB: 0, - }, - }, - }), + cacheHandler, recaptcha.V3( recaptcha.Options{ Secret: conf.Recaptcha.ServerKey, diff --git a/internal/shared/redis.go b/internal/shared/redis.go new file mode 100644 index 0000000..d192a73 --- /dev/null +++ b/internal/shared/redis.go @@ -0,0 +1,45 @@ +// 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 shared + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +// RedisPingTimeout 是启动期探活的总超时。 +// 刻意短:启动阶段卡住比启动失败更难排查,3 秒足够区分「连不上」与「慢」。 +const RedisPingTimeout = 3 * time.Second + +// PingRedis 在启动时探一次 Redis 连通性,用于 fail fast。 +// +// 为什么必须做这件事:session 建在 Redis 上(route.go 的 sessioner),而 +// flamego/session 对 load 错误是直接 panic,被 Recovery 兜成 500 —— +// 也就是 **Redis 一宕,每个请求都 500,包括完全未登录的访客**。 +// 等到第一个真实请求才暴露时,运维看到的现场是「站点全 500」, +// 很难第一时间联想到 Redis;启动期直接报错退出,信号干净得多。 +// +// 同时显式设置读写超时:go-redis 默认 Dial 5s / Read 3s,Redis 半死不活 +// (进程在但不响应)时会让每个请求白等这几秒,表现为整站变慢而无报错。 +func PingRedis(addr, password string) error { + ctx, cancel := context.WithTimeout(context.Background(), RedisPingTimeout) + defer cancel() + + rdb := redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + // DB 1 与 route.go 里 session 用的库保持一致:探活要探真正会用到的那一个, + // 否则「DB 0 能用、DB 1 被 ACL 禁掉」这种配置错误探不出来。 + DB: 1, + DialTimeout: 2 * time.Second, + ReadTimeout: 2 * time.Second, + WriteTimeout: 2 * time.Second, + }) + defer func() { _ = rdb.Close() }() + + return rdb.Ping(ctx).Err() +}