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:
tamakyi
2026-10-29 15:53:32 +08:00
parent 46932b4c53
commit 11d6e701ee
6 changed files with 287 additions and 15 deletions
+64 -3
View File
@@ -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
}