fix(ops): 访问日志脱敏、优雅关闭、Redis 可降级并启动即探活
三处都属于「故障时才发现」的运维缺口,一起改。
1) 访问日志泄露凭证
flamego.Classic() 内部 Use(Logger()),而 Logger 记的是 RequestURI —— 含 query。
实测日志出现 path=/_/tblocal/4?t=SECRETTOKEN123:?t= 是私密提问钥匙,
?code= 是一次性改密凭证,?q= 是搜索词,全都会被明文落盘,凡是能看日志的人
都能直接读私密提问或重置密码。internal/tracing 早就为同一原因只取 URL.Path
并留了注释,漏的是框架自带那层。
→ 改 flamego.New() + accessLogMiddleware,只记 Method/Path/Status/Duration/IP;
静态资源成功请求不记(噪音),4xx/5xx 照记(排查裂图靠它)。
2) 没有优雅关闭
直接 ListenAndServe,Go 对 SIGTERM 是立即退出,每次部署都会把正在写库的 POST
就地处死,留下「提问已 INSERT、图片没传完」这类半成品。
→ signal.NotifyContext(SIGTERM/SIGINT) + srv.Shutdown(15s),超时才 Close;
退出前关连接池。实测 8 个并发分享图请求中发 SIGTERM,8/8 全部成功。
3) Redis 单点
session 建在 Redis 上,load 失败 flamego/session 会 panic → 每请求 500(含匿名访客);
而 cache.Cacher 没判空 Addr,go-redis 会回退 localhost:6379,于是「不配 Redis」
这种被允许的部署里找回密码整体不可用、私信邮件节流失效,且启动零提示。
→ cache 跟随开关(未配则 MemoryIniter);启动 PingRedis fail-fast,显式 2s 超时。
实测停掉 Redis 后启动立即失败并明确指出是 Redis。
验证:单测 2 项(query 永不入日志 / 静态资源过滤口径);重启日志出现
「收到退出信号」→「HTTP 服务已关闭」;启动打「Redis 连接正常」。
This commit is contained in:
+64
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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**
|
||||
// ([email protected]/logger.go:36,45)。本站有几个参数绝对不能进日志:
|
||||
//
|
||||
// ?t=<token> 私密提问的访问钥匙(route/question/question.go)
|
||||
// ?code=<code> 一次性改密凭证(route/auth/recover.go)
|
||||
// ?q=<keyword> 探索页搜索词
|
||||
//
|
||||
// 拿到 ?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),
|
||||
}
|
||||
}
|
||||
@@ -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/ 前缀才算静态资源")
|
||||
}
|
||||
+36
-11
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user