package config import ( "crypto/rand" "encoding/hex" "fmt" "log" "os" "path/filepath" "strings" "sync" ) var ( instance *Config once sync.Once ) type Config struct { ServerPort string DatabasePath string JWTSecret string EncryptionKey string // 独立加密密钥,必须与JWT_SECRET分离 DataDir string KubeDir string LogLevel string // 新增:日志级别 debug/info/warn/error HostKeyDir string // 新增:SSH主机密钥存储目录 LicenseAPIBase string // opsx-licence API 根(须以 /api 结尾) } // resolveSecrets 密钥解析(所有部署形态统一的零配置体验): // 显式环境变量优先;缺失时读取 DATA_DIR/.secrets.env;两者皆无则生成随机密钥 // 并持久化(0600),重启/升级保持不变。无法持久化时拒绝启动,绝不使用易失密钥。 func resolveSecrets(dataDir string) (jwt, enc string) { jwt = os.Getenv("JWT_SECRET") enc = os.Getenv("ENCRYPTION_KEY") if jwt != "" && enc != "" { return jwt, enc } secretsFile := filepath.Join(dataDir, ".secrets.env") if data, err := os.ReadFile(secretsFile); err == nil { for _, line := range strings.Split(string(data), "\n") { if v, ok := strings.CutPrefix(line, "JWT_SECRET="); ok && jwt == "" && v != "" { jwt = v } if v, ok := strings.CutPrefix(line, "ENCRYPTION_KEY="); ok && enc == "" && v != "" { enc = v } } } if jwt != "" && enc != "" { return jwt, enc } gen := func() string { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { log.Fatal("[SECURITY] 生成随机密钥失败: ", err) } return hex.EncodeToString(buf) } if jwt == "" { jwt = gen() log.Printf("[SECURITY] JWT_SECRET 未设置,已生成随机密钥并持久化到 %s", secretsFile) } if enc == "" { enc = gen() log.Printf("[SECURITY] ENCRYPTION_KEY 未设置,已生成随机密钥并持久化到 %s", secretsFile) } if err := os.MkdirAll(dataDir, 0o700); err != nil { log.Fatal("[SECURITY] 无法创建数据目录以持久化密钥: ", err) } if err := os.WriteFile(secretsFile, []byte(fmt.Sprintf( "# LokiStack auto-generated. Delete to regenerate.\nJWT_SECRET=%s\nENCRYPTION_KEY=%s\n", jwt, enc, )), 0o600); err != nil { log.Fatal("[SECURITY] 密钥持久化失败(请显式设置 JWT_SECRET / ENCRYPTION_KEY): ", err) } return jwt, enc } func GetConfig() *Config { once.Do(func() { dataDir := getEnv("DATA_DIR", "./data") jwtSecret, encKey := resolveSecrets(dataDir) // ENCRYPTION_KEY 长度校验:至少32字节(256位) if len(encKey) < 32 { log.Fatal(fmt.Sprintf("[SECURITY] ENCRYPTION_KEY must be at least 32 bytes, got %d bytes", len(encKey))) } instance = &Config{ ServerPort: getEnv("SERVER_PORT", "8080"), DatabasePath: databasePath(dataDir), JWTSecret: jwtSecret, EncryptionKey: encKey, DataDir: dataDir, KubeDir: filepath.Join(dataDir, "kubeconfigs"), LogLevel: getEnv("LOG_LEVEL", "info"), HostKeyDir: filepath.Join(dataDir, "known_hosts"), LicenseAPIBase: resolveLicenseAPIBase(), } _ = os.MkdirAll(instance.KubeDir, 0700) _ = os.MkdirAll(instance.HostKeyDir, 0700) }) return instance } // resolveLicenseAPIBase 国内主节点默认;LICENSE_API_BASE / OPSX_LICENSE_API_BASE 可覆盖。 // 备灾节点 l123 不写死进默认路径,需显式配置。 func resolveLicenseAPIBase() string { for _, key := range []string{"LICENSE_API_BASE", "OPSX_LICENSE_API_BASE"} { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return normalizeLicenseAPIBase(v) } } return "https://licence.rlwj.cn/api" } func normalizeLicenseAPIBase(raw string) string { base := strings.TrimRight(strings.TrimSpace(raw), "/") if base == "" { return "https://licence.rlwj.cn/api" } if strings.HasSuffix(base, "/api/v1") { return strings.TrimSuffix(base, "/v1") } if strings.HasSuffix(base, "/v1") && !strings.HasSuffix(base, "/api/v1") { return strings.TrimSuffix(base, "/v1") + "/api" } if !strings.HasSuffix(base, "/api") { return base + "/api" } return base } func getEnv(key, defaultVal string) string { if val := os.Getenv(key); val != "" { return val } return defaultVal } // databasePath 新名优先;历史命名的数据文件存在且新文件不存在时沿用旧文件 // (数据平滑迁移,不搬移文件、零丢失风险) func databasePath(dataDir string) string { newPath := filepath.Join(dataDir, "lokistack.json") if _, err := os.Stat(newPath); err == nil { return newPath } for _, legacy := range []string{"opsxloki.json", "lokideploy.json"} { legacyPath := filepath.Join(dataDir, legacy) if _, err := os.Stat(legacyPath); err == nil { return legacyPath } } return newPath }