Files
tamabox/internal/cmd/web.go
T
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

211 lines
7.7 KiB
Go
Raw 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 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 (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"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/stats"
"github.com/tamakyi/TamaBox/internal/tracing"
"github.com/tamakyi/TamaBox/route/question"
)
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
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 是只追加的审计表,写入完全由
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
// 保留期复用 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)
}
// 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)
shared.App.SetLang(conf.App.DefaultLang)
// 分享卡字体:缺字体只会在「第一张分享图被请求」时暴露成 500,而 /share-card 是公开端点。
// 启动时先探一次(顺带把读文件 + 解析的开销挪出请求路径),缺了直接 WARN 并给出补救办法。
if fontPath, err := question.CheckShareCardFont(); err != nil {
logrus.WithError(err).Warn("分享卡字体不可用,/share-card 将返回 500:" +
"装一个 glyf 轮廓的 CJK 字体(apt install fonts-wqy-zenhei)" +
"或把 ttf/otf/ttc 放进 static/fonts/,也可用 [app] font_path 指定")
} else {
logrus.WithField("font", fontPath).Info("分享卡字体已就绪")
}
r := route.New()
r.Use(tracing.Middleware("TamaBox"))
// 全站数据页预热:整页聚合是若干次全表 GROUP BY,若摊在当天第一个访客身上,
// 首屏要白等一秒以上。这里延后几秒在后台先算一次,把等待挪出请求路径。
// 失败只告警:页面在真正被访问时会自己重算并(若仍失败)回退到上一次成功的结果。
go func() {
time.Sleep(3 * time.Second)
if _, err := stats.OverviewData(ctx.Context); err != nil {
logrus.WithError(err).Warn("预热全站统计数据失败")
}
}()
// 最外层挂载 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")
// 优雅关闭:收到 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
}