// 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/insights" "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") // 箱主数据面板的访问记录器:异步批量写 box_views,不阻塞页面渲染。 // 密钥复用 server.xsrf_key —— 它已经是启动期强校验过的非空随机串, // 而且与 CSRF token 的用途不同(这里只做 HMAC 的一次性密钥),不存在混用风险。 insightsStore := db.BoxViews if insightsStore != nil { insights.Views = insights.NewRecorder(insightsStore, conf.Server.XSRFKey) defer insights.Views.Close() insights.StartMaintenance(ctx.Context, insightsStore) } // 站点默认语言初始化(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 防止慢速客户端占用连接(slowloris 的防线在这里) // - ReadTimeout / WriteTimeout 防止读写挂死 // - IdleTimeout 复用 keep-alive 连接 // listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为 // "127.0.0.1:",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。 // // ReadTimeout 从 30s 提到 120s:站内允许单图 8MB(storage.MaxUploadBytes), // 请求体上限 12MB(form.MaxRequestBodySize)。30s 传完 8MB 需要 ≥273 KB/s 上行, // 而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,且用户 // 已经写好的提问正文会一起丢掉(POST 失败不回填)。120s 对应约 68 KB/s, // 偏远地区 3G 也能过。 // // 放宽 ReadTimeout 不会让 slowloris 重新变得可行:那类攻击卡在「发不完 header」, // 由 10s 的 ReadHeaderTimeout 拦住;ReadTimeout 只影响读完 header 之后传 body 的时长。 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: 120 * 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 }