以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。
新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
(一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
每次运行精修到不同 IP,配额花在长尾上。
报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。
存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)
配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。
顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
244 lines
9.7 KiB
Go
244 lines
9.7 KiB
Go
// 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 用的句柄不会争抢同一天的文件。
|
||
// 两个后台任务(保留期清理 / 周期报告)共用一个停止信号,
|
||
// defer 必须放在 if 外面 —— 否则「自动清理关掉、报告开着」时这个
|
||
// channel 永远不会被关闭,那个 goroutine 就一直挂着。
|
||
stopRetention := make(chan struct{})
|
||
defer close(stopRetention)
|
||
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)
|
||
}
|
||
|
||
// 周期安全汇总(周报 / 月报):把命中按 IP 定位到城市,出地图报告并邮件送达。
|
||
// 幂等靠 security_report_logs 的 period_key 唯一索引,所以这里只管定期看一眼
|
||
// 「该发的那期发过没」—— 重启、错过时间窗都能正确补发,也不会重复发。
|
||
if conf.Security.HoneypotReportEnabled {
|
||
if reportRecipient() == "" {
|
||
logrus.Warn("已启用安全汇总报告,但 honeypot_report_email 与 security_alert_email 都为空,将无法送达")
|
||
}
|
||
NewSecurityReportScheduler(gdb).Start(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:<port>",使源站端口仅对本地 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
|
||
}
|