26.9(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务: - 安全审计全部修复(docs/security-audit-2026-09-05.md): bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、 全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、 管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、 会话默认 7 天、限流缓存故障降级、robots.txt 端点等 - 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固 - Redis 支持库号(FCB_REDIS_DB / redis://…/db URL) - 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、 提码 5 位起、chunk 32MiB 上限、admin 审计动作等) 验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
// 文件快递柜 Go 重写版服务入口:
|
||||
// 装配 配置 → Postgres → 缓存 → 设置 → 审计 → 存储引擎 → 中间件 → API 路由。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/api"
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/cache"
|
||||
"filecodebox/internal/config"
|
||||
"filecodebox/internal/database"
|
||||
"filecodebox/internal/janitor"
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/settings"
|
||||
"filecodebox/internal/storage"
|
||||
)
|
||||
|
||||
// APP_VERSION 版本号,对齐参考仓库 VERSION。
|
||||
const APP_VERSION = "2.5.6"
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// 1. 配置(环境变量基线;引擎相关 env 种子在此注入,DB KV 仍可覆盖)
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 配置加载失败: %v", err)
|
||||
}
|
||||
applyEnvEngineSeeds(cfg)
|
||||
|
||||
// 2. 数据库(需求 ⑧:默认 SQLite 零依赖;配置 FCB_DB_DRIVER=postgres+DSN 后走 Postgres)
|
||||
db, err := database.Open(ctx, database.Options{Driver: cfg.Env.DBDriver, DSN: cfg.Env.DBDSN})
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 数据库初始化失败: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close(db) }()
|
||||
if err := database.Migrate(ctx, db); err != nil {
|
||||
log.Fatalf("[boot] 数据库迁移失败: %v", err)
|
||||
}
|
||||
if cfg.Env.DBDriver == config.DBDriverPostgres {
|
||||
log.Printf("[boot] Postgres 数据库已启用")
|
||||
} else {
|
||||
log.Printf("[boot] SQLite 数据库: %s", cfg.SQLitePath())
|
||||
}
|
||||
|
||||
// 3. 缓存:Redis 可选,未配置则内存降级(需求 ⑧:默认即内存,Redis 为增强项)
|
||||
cacheImpl, err := cache.New(ctx, cache.RedisOptions{Addr: cfg.Env.RedisAddr, DB: cfg.Env.RedisDB})
|
||||
if err != nil {
|
||||
log.Printf("[boot] Redis 连接失败,降级为内存缓存: %v", err)
|
||||
cacheImpl, err = cache.NewMemory(), nil
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 内存缓存初始化失败: %v", err)
|
||||
}
|
||||
}
|
||||
defer func() { _ = cacheImpl.Close() }()
|
||||
if cfg.Env.RedisAddr != "" {
|
||||
if cfg.Env.RedisDB != 0 {
|
||||
log.Printf("[boot] Redis 缓存已启用: %s (db=%d)", cfg.Env.RedisAddr, cfg.Env.RedisDB)
|
||||
} else {
|
||||
log.Printf("[boot] Redis 缓存已启用: %s", cfg.Env.RedisAddr)
|
||||
}
|
||||
} else {
|
||||
log.Println("[boot] 未配置 FCB_REDIS_ADDR,使用内存缓存")
|
||||
}
|
||||
|
||||
// 4. 设置管理器(env 基线 + DB settings KV 运行时覆盖)
|
||||
mgr, err := settings.NewManager(ctx, db, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 设置加载失败: %v", err)
|
||||
}
|
||||
mgr.SystemStart(ctx)
|
||||
autoInitIfNeeded(mgr)
|
||||
|
||||
// 5. 审计服务 + 重试循环(需求 ③)
|
||||
auditSvc := audit.NewService(audit.NewDBSink(db))
|
||||
auditStop := make(chan struct{})
|
||||
defer close(auditStop)
|
||||
auditSvc.StartRetryLoop(auditStop)
|
||||
|
||||
// 6. 限流器(规则来自 settings KV,可被管理端动态调整)
|
||||
limiter := middleware.NewRateLimiter(cacheImpl, map[string]middleware.LimitRule{
|
||||
middleware.LimitError: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
|
||||
middleware.LimitUpload: {Count: cfg.GetInt("uploadCount"), Window: minutes(cfg.GetInt("uploadMinute"))},
|
||||
middleware.LimitLogin: {Count: cfg.GetInt("loginCount"), Window: minutes(cfg.GetInt("loginMinute"))},
|
||||
middleware.LimitMeta: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
|
||||
})
|
||||
|
||||
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;v3 包装为可热切换 Manager)
|
||||
storage.SetEngineOptions(buildEngineOptions(cfg))
|
||||
bootStore, err := storage.NewEngine(ctx, cfg.Engine())
|
||||
if err != nil {
|
||||
log.Fatalf("[boot] 存储引擎 %s 初始化失败: %v", cfg.Engine(), err)
|
||||
}
|
||||
if err := bootStore.HealthCheck(ctx); err != nil {
|
||||
// 预检失败仅告警不阻断启动(存储可能在运行中恢复)
|
||||
log.Printf("[boot] 警告: 存储引擎 %s 健康检查未通过: %v", cfg.Engine(), err)
|
||||
} else {
|
||||
log.Printf("[boot] 存储引擎 %s 健康检查通过", cfg.Engine())
|
||||
}
|
||||
// v3:Manager 包装——保存/读取委托当前引擎;管理端可热切换
|
||||
//(构建闭包在每次切换前用最新 KV 刷新 EngineOptions,参数改动即时生效)
|
||||
store := storage.NewManager(cfg.Engine(), bootStore, func(name string) (storage.Storage, error) {
|
||||
storage.SetEngineOptions(buildEngineOptions(cfg))
|
||||
return storage.NewEngine(ctx, name)
|
||||
})
|
||||
|
||||
// 8. 路由装配
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.Use(middleware.ClientIP(middleware.ParseTrustedProxies(cfg.Env.TrustedProxies)))
|
||||
// L6:管理端 CORS 收紧(允许站点对外域名 site_domain 跨域调用,其余拒绝)
|
||||
r.Use(middleware.Cors(cfg.SiteDomain()))
|
||||
// 未初始化守卫:除 /setup 与 /api/v1/health 外一律 428
|
||||
r.Use(middleware.GuardNotInitialized(mgr.IsInitialized))
|
||||
// M3:全局请求体大小上限(管理端 1MiB;上传类 = 单文件上限 + 表单开销)
|
||||
r.Use(middleware.BodyLimit(bodyLimitFn(cfg)))
|
||||
// 审计中间件:按 DefaultClassifier 路由模式自动分类 upload/download(需求 ③)
|
||||
r.Use(middleware.Audit(auditSvc, nil))
|
||||
|
||||
api.Register(r, &api.Deps{
|
||||
DB: db,
|
||||
Cfg: cfg,
|
||||
Mgr: mgr,
|
||||
AuditSvc: auditSvc,
|
||||
Limiter: limiter,
|
||||
Store: store,
|
||||
Version: APP_VERSION,
|
||||
})
|
||||
|
||||
// 9. HTTP 服务
|
||||
// M5:后台清理循环(过期预留/超时会话/直传残留对象),启动后 10 分钟首跑
|
||||
janitor.Start(ctx, db, store, 10*time.Minute)
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Env.Listen,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("[boot] 文件快递柜 %s 启动于 %s(存储引擎 %s)", APP_VERSION, cfg.Env.Listen, cfg.Engine())
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[boot] HTTP 服务异常退出: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("[boot] 收到退出信号,开始优雅关闭...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("[boot] 优雅关闭超时: %v", err)
|
||||
}
|
||||
log.Println("[boot] 服务已退出")
|
||||
}
|
||||
|
||||
// autoInitIfNeeded 安全审计 L1:系统未初始化且配置了 FCB_ADMIN_PASSWORD 时,
|
||||
// 启动即自动完成管理员初始化,消除「部署到公网后被抢先访问 /setup 接管」的窗口。
|
||||
// 密码不足 8 位时拒绝并保持 /setup 可用(不静默采用弱口令)。
|
||||
func autoInitIfNeeded(mgr *settings.Manager) {
|
||||
if mgr.IsInitialized() {
|
||||
return
|
||||
}
|
||||
pwd := strings.TrimSpace(os.Getenv("FCB_ADMIN_PASSWORD"))
|
||||
if pwd == "" {
|
||||
return
|
||||
}
|
||||
if len(pwd) < 8 {
|
||||
log.Printf("[boot] 警告: FCB_ADMIN_PASSWORD 少于 8 位,已忽略;/setup 初始化向导保持可用")
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := mgr.UpdateKV(ctx, map[string]any{
|
||||
"admin_token": settings.HashPassword(pwd),
|
||||
"jwt_secret": settings.GenerateJWTSecret(),
|
||||
}); err != nil {
|
||||
log.Printf("[boot] 警告: FCB_ADMIN_PASSWORD 自动初始化写入失败: %v(/setup 仍可用)", err)
|
||||
return
|
||||
}
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
log.Printf("[boot] 警告: 自动初始化后配置重载失败: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[boot] 已通过 FCB_ADMIN_PASSWORD 自动完成管理员初始化")
|
||||
}
|
||||
|
||||
// envKVSeeds 容器编排常用的 FCB_* 环境变量 → settings KV 键映射。
|
||||
// config 包只解析进程必需的 5 个变量;引擎相关配置通过本桥接注入,
|
||||
// 使 docker-compose 无需进库即可完成引擎配置。优先级:defaults < 环境变量种子 < DB settings KV。
|
||||
var envKVSeeds = []struct {
|
||||
envKey string
|
||||
kvKey string
|
||||
}{
|
||||
{"FCB_LOCAL_STORAGE_PATH", "local_storage_path"},
|
||||
{"FCB_STORAGE_PATH", "storage_path"},
|
||||
{"FCB_S3_ACCESS_KEY_ID", "s3_access_key_id"},
|
||||
{"FCB_S3_SECRET_ACCESS_KEY", "s3_secret_access_key"},
|
||||
{"FCB_AWS_SESSION_TOKEN", "aws_session_token"},
|
||||
{"FCB_S3_BUCKET_NAME", "s3_bucket_name"},
|
||||
{"FCB_S3_ENDPOINT_URL", "s3_endpoint_url"},
|
||||
{"FCB_S3_REGION_NAME", "s3_region_name"},
|
||||
{"FCB_S3_ADDRESSING_STYLE", "s3_addressing_style"},
|
||||
{"FCB_WEBDAV_URL", "webdav_url"},
|
||||
{"FCB_WEBDAV_USERNAME", "webdav_username"},
|
||||
{"FCB_WEBDAV_PASSWORD", "webdav_password"},
|
||||
{"FCB_WEBDAV_ROOT_PATH", "webdav_root_path"},
|
||||
}
|
||||
|
||||
// applyEnvEngineSeeds 把已设置的环境变量作为 KV 种子写入配置(仅种子,不落库)。
|
||||
func applyEnvEngineSeeds(cfg *config.Config) {
|
||||
seeds := map[string]any{}
|
||||
for _, m := range envKVSeeds {
|
||||
if v := strings.TrimSpace(os.Getenv(m.envKey)); v != "" {
|
||||
seeds[m.kvKey] = v
|
||||
}
|
||||
}
|
||||
// L8:管理员会话有效期支持环境变量(秒;1~365 整天校验在 AdminSessionExpireSeconds)
|
||||
if v := strings.TrimSpace(os.Getenv("FCB_ADMIN_SESSION_EXPIRE")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
seeds["adminSessionExpire"] = n
|
||||
}
|
||||
}
|
||||
if len(seeds) > 0 {
|
||||
cfg.ApplyKV(seeds)
|
||||
log.Printf("[boot] 已从环境变量注入 %d 项配置种子", len(seeds))
|
||||
}
|
||||
}
|
||||
|
||||
// buildEngineOptions 从配置构造引擎选项(对齐 go-storage 的注入约定:
|
||||
// 必须在 NewEngine 之前调用;字段来源为 config KV,键名对齐参考实现)。
|
||||
func buildEngineOptions(cfg *config.Config) storage.EngineOptions {
|
||||
return storage.EngineOptions{
|
||||
Local: storage.LocalOptions{
|
||||
Root: cfg.GetString("local_storage_path"),
|
||||
},
|
||||
S3: storage.S3Options{
|
||||
AccessKeyID: cfg.GetString("s3_access_key_id"),
|
||||
SecretAccessKey: cfg.GetString("s3_secret_access_key"),
|
||||
SessionToken: cfg.GetString("aws_session_token"),
|
||||
Bucket: cfg.GetString("s3_bucket_name"),
|
||||
Endpoint: cfg.GetString("s3_endpoint_url"),
|
||||
Region: cfg.GetString("s3_region_name"),
|
||||
AddressingStyle: cfg.GetString("s3_addressing_style"),
|
||||
},
|
||||
WebDAV: storage.WebDAVOptions{
|
||||
BaseURL: cfg.GetString("webdav_url"),
|
||||
Username: cfg.GetString("webdav_username"),
|
||||
Password: cfg.GetString("webdav_password"),
|
||||
RootPath: cfg.GetString("webdav_root_path"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// minutes 分钟数转 Duration。
|
||||
func minutes(n int) time.Duration {
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
}
|
||||
return time.Duration(n) * time.Minute
|
||||
}
|
||||
|
||||
// bodyLimitFn 构造按路径分类的请求体上限函数(M3):
|
||||
// - /admin/*:1MiB(管理接口均为小 JSON/表单);
|
||||
// - /share/text|metadata|select、/setup:1MiB(文本内容本身限 222KB);
|
||||
// - 其余(上传类):max_file_size(0=回落 uploadSize,再 0=64MiB)+ 2MiB 表单开销。
|
||||
func bodyLimitFn(cfg *config.Config) func(c *gin.Context) int64 {
|
||||
const adminLimit = int64(1) << 20
|
||||
const textLimit = int64(1) << 20
|
||||
const formOverhead = int64(2) << 20
|
||||
const fallbackUpload = int64(64) << 20
|
||||
return func(c *gin.Context) int64 {
|
||||
p := c.Request.URL.Path
|
||||
switch {
|
||||
case p == "/setup" || strings.HasPrefix(p, "/admin/"):
|
||||
return adminLimit
|
||||
case p == "/share/text" || p == "/share/metadata" || p == "/share/select":
|
||||
return textLimit
|
||||
}
|
||||
limit := cfg.MaxFileSize()
|
||||
if limit <= 0 {
|
||||
limit = cfg.UploadSize()
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = fallbackUpload
|
||||
}
|
||||
// 分片/直传单请求体 ≤ 单片大小;share/file 的 multipart 有边界开销
|
||||
return limit + formOverhead
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user