Files
tamakyi 11d6e701ee 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 连接正常」。
2026-10-29 15:53:32 +08:00

75 lines
3.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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),
}
}