From 7dbbfc22b69edcc28a315e1a0badcac1885b87d9 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 6 Jan 2026 09:43:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=20release=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20JWT=20Secret=20=E5=BF=85=E5=A1=AB=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=20Docker=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 Install() 和 AutoSetupFromEnv() 中 release 模式下 JWT Secret 必填检查 - 移除 config.Validate() 中 release 模式下的 JWT 验证 - 新增 GetDataDir() 函数,自动检测数据目录:DATA_DIR 环境变量 > /app/data > 当前目录 - config.yaml 和 .installed 文件现在写入正确的数据目录 - config.Load() 添加 /app/data 到配置搜索路径 这修复了两个问题: 1. Web Setup Wizard 在 release 模式下无法完成安装 2. Docker 部署时 config.yaml 未被持久化导致每次重启重新初始化 --- backend/internal/config/config.go | 29 +++++++------- backend/internal/setup/setup.go | 64 +++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0490ed06..cb5c5afa 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "log" + "os" "strings" "time" @@ -338,8 +339,19 @@ func NormalizeRunMode(value string) string { func Load() (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("yaml") + + // Add config paths in priority order + // 1. DATA_DIR environment variable (highest priority) + if dataDir := os.Getenv("DATA_DIR"); dataDir != "" { + viper.AddConfigPath(dataDir) + } + // 2. Docker data directory + viper.AddConfigPath("/app/data") + // 3. Current directory viper.AddConfigPath(".") + // 4. Config subdirectory viper.AddConfigPath("./config") + // 5. System config directory viper.AddConfigPath("/etc/sub2api") // 环境变量支持 @@ -372,13 +384,13 @@ func Load() (*Config, error) { cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove) cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy) - if cfg.Server.Mode != "release" && cfg.JWT.Secret == "" { + if cfg.JWT.Secret == "" { secret, err := generateJWTSecret(64) if err != nil { return nil, fmt.Errorf("generate jwt secret error: %w", err) } cfg.JWT.Secret = secret - log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.") + log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.") } if err := cfg.Validate(); err != nil { @@ -392,7 +404,7 @@ func Load() (*Config, error) { log.Println("Warning: security.response_headers.enabled=false; configurable header filtering disabled (default allowlist only).") } - if cfg.Server.Mode != "release" && cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) { + if cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) { log.Println("Warning: JWT secret appears weak; use a 32+ character random secret in production.") } if len(cfg.Security.ResponseHeaders.AdditionalAllowed) > 0 || len(cfg.Security.ResponseHeaders.ForceRemove) > 0 { @@ -549,17 +561,6 @@ func setDefaults() { } func (c *Config) Validate() error { - if c.Server.Mode == "release" { - if c.JWT.Secret == "" { - return fmt.Errorf("jwt.secret is required in release mode") - } - if len(c.JWT.Secret) < 32 { - return fmt.Errorf("jwt.secret must be at least 32 characters") - } - if isWeakJWTSecret(c.JWT.Secret) { - return fmt.Errorf("jwt.secret is too weak") - } - } if c.JWT.ExpireHour <= 0 { return fmt.Errorf("jwt.expire_hour must be positive") } diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index ad077735..4b1ca7b2 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -9,7 +9,6 @@ import ( "log" "os" "strconv" - "strings" "time" "github.com/Wei-Shaw/sub2api/internal/repository" @@ -22,10 +21,44 @@ import ( // Config paths const ( - ConfigFile = "config.yaml" - EnvFile = ".env" + ConfigFileName = "config.yaml" + InstallLockFile = ".installed" ) +// GetDataDir returns the data directory for storing config and lock files. +// Priority: DATA_DIR env > /app/data (if exists and writable) > current directory +func GetDataDir() string { + // Check DATA_DIR environment variable first + if dir := os.Getenv("DATA_DIR"); dir != "" { + return dir + } + + // Check if /app/data exists and is writable (Docker environment) + dockerDataDir := "/app/data" + if info, err := os.Stat(dockerDataDir); err == nil && info.IsDir() { + // Try to check if writable by creating a temp file + testFile := dockerDataDir + "/.write_test" + if f, err := os.Create(testFile); err == nil { + f.Close() + os.Remove(testFile) + return dockerDataDir + } + } + + // Default to current directory + return "." +} + +// GetConfigFilePath returns the full path to config.yaml +func GetConfigFilePath() string { + return GetDataDir() + "/" + ConfigFileName +} + +// GetInstallLockPath returns the full path to .installed lock file +func GetInstallLockPath() string { + return GetDataDir() + "/" + InstallLockFile +} + // SetupConfig holds the setup configuration type SetupConfig struct { Database DatabaseConfig `json:"database" yaml:"database"` @@ -72,13 +105,12 @@ type JWTConfig struct { // Uses multiple checks to prevent attackers from forcing re-setup by deleting config func NeedsSetup() bool { // Check 1: Config file must not exist - if _, err := os.Stat(ConfigFile); !os.IsNotExist(err) { + if _, err := os.Stat(GetConfigFilePath()); !os.IsNotExist(err) { return false // Config exists, no setup needed } // Check 2: Installation lock file (harder to bypass) - lockFile := ".installed" - if _, err := os.Stat(lockFile); !os.IsNotExist(err) { + if _, err := os.Stat(GetInstallLockPath()); !os.IsNotExist(err) { return false // Lock file exists, already installed } @@ -197,17 +229,12 @@ func Install(cfg *SetupConfig) error { // Generate JWT secret if not provided if cfg.JWT.Secret == "" { - if strings.EqualFold(cfg.Server.Mode, "release") { - return fmt.Errorf("jwt secret is required in release mode") - } secret, err := generateSecret(32) if err != nil { return fmt.Errorf("failed to generate jwt secret: %w", err) } cfg.JWT.Secret = secret - log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.") - } else if strings.EqualFold(cfg.Server.Mode, "release") && len(cfg.JWT.Secret) < 32 { - return fmt.Errorf("jwt secret must be at least 32 characters in release mode") + log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.") } // Test connections @@ -244,9 +271,8 @@ func Install(cfg *SetupConfig) error { // createInstallLock creates a lock file to prevent re-installation attacks func createInstallLock() error { - lockFile := ".installed" content := fmt.Sprintf("installed_at=%s\n", time.Now().UTC().Format(time.RFC3339)) - return os.WriteFile(lockFile, []byte(content), 0400) // Read-only for owner + return os.WriteFile(GetInstallLockPath(), []byte(content), 0400) // Read-only for owner } func initializeDatabase(cfg *SetupConfig) error { @@ -397,7 +423,7 @@ func writeConfigFile(cfg *SetupConfig) error { return err } - return os.WriteFile(ConfigFile, data, 0600) + return os.WriteFile(GetConfigFilePath(), data, 0600) } func generateSecret(length int) (string, error) { @@ -440,6 +466,7 @@ func getEnvIntOrDefault(key string, defaultValue int) int { // This is designed for Docker deployment where all config is passed via env vars func AutoSetupFromEnv() error { log.Println("Auto setup enabled, configuring from environment variables...") + log.Printf("Data directory: %s", GetDataDir()) // Get timezone from TZ or TIMEZONE env var (TZ is standard for Docker) tz := getEnvOrDefault("TZ", "") @@ -481,17 +508,12 @@ func AutoSetupFromEnv() error { // Generate JWT secret if not provided if cfg.JWT.Secret == "" { - if strings.EqualFold(cfg.Server.Mode, "release") { - return fmt.Errorf("jwt secret is required in release mode") - } secret, err := generateSecret(32) if err != nil { return fmt.Errorf("failed to generate jwt secret: %w", err) } cfg.JWT.Secret = secret - log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.") - } else if strings.EqualFold(cfg.Server.Mode, "release") && len(cfg.JWT.Secret) < 32 { - return fmt.Errorf("jwt secret must be at least 32 characters in release mode") + log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.") } // Generate admin password if not provided