package config import ( "crypto/rand" "encoding/hex" "log" "os" "path/filepath" "runtime" "gopkg.in/yaml.v3" ) // Config 保存全部应用配置。 type Config struct { Database DatabaseConfig `yaml:"database"` Web WebConfig `yaml:"web"` Path string `yaml:"path"` Secret string `yaml:"secret"` } // DatabaseConfig 保存数据库相关配置。 type DatabaseConfig struct { Type string `yaml:"type"` // "sqlite"(默认)或 "mysql" DSN string `yaml:"dsn"` // MySQL 连接字符串(type 为 "mysql" 时必填) } // WebConfig 保存 Web 服务器监听配置。 type WebConfig struct { Port string `yaml:"port"` // TCP 端口,"" 或 "0" 表示禁用 Socket string `yaml:"socket"` // Unix Socket 路径,"" 表示禁用 // TrustedProxies 列出可信代理 IP/CIDR,这些代理的 X-Forwarded-For / // X-Forwarded-Proto 请求头将被信任(例如位于应用前方的 Caddy/nginx // 服务器)。默认为回环地址。如果应用直接暴露给客户端,请保持默认值, // 以免客户端伪造 X-Forwarded-For 欺骗记录中的 IP。 TrustedProxies []string `yaml:"trusted_proxies"` } // defaultTrustedProxies 在配置中省略 trusted_proxies 时使用。 var defaultTrustedProxies = []string{"127.0.0.1", "::1"} const defaultPort = "8080" // mysqlExampleDSN 会写入新建的配置文件,作为参考示例。 const mysqlExampleDSN = "user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local" // getConfigPath 返回按操作系统区分的配置目录和配置文件路径。 func getConfigPath() (dir, file string) { switch runtime.GOOS { case "windows": dir = filepath.Join(".", "win", "etc", "blog_go") case "darwin": dir = filepath.Join(".", "mac", "etc", "blog_go") default: dir = filepath.Join("/", "etc", "blog_go") } file = filepath.Join(dir, "config.yaml") return } // getDefaultStoragePath 返回按操作系统区分的默认存储路径。 func getDefaultStoragePath() string { switch runtime.GOOS { case "windows": return filepath.Join(".", "win", "srv", "blog_go") case "darwin": return filepath.Join(".", "mac", "srv", "blog_go") default: return filepath.Join("/", "srv", "blog_go") } } // generateSecret 为会话密钥生成密码学随机的十六进制字符串。 // crypto/rand 的失败无法恢复,因此程序将直接终止,而不会退回到可预测的值。 func generateSecret() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { log.Fatalf("Failed to generate session secret: %v", err) } return hex.EncodeToString(b) } // getDefaultSocketPath 返回按操作系统区分的默认 Unix Socket 路径。 func getDefaultSocketPath() string { if runtime.GOOS == "linux" { return "/run/blog_go/web.sock" } return "" } // LoadConfig 读取配置文件;若不存在则按默认值创建。 // 若 customPath 非空,则覆盖按操作系统区分的配置文件路径。 func LoadConfig(customPath string) *Config { configDir, configFile := getConfigPath() if customPath != "" { configFile = customPath configDir = filepath.Dir(configFile) } defaultPath := getDefaultStoragePath() // 检查配置文件是否存在;不存在则按默认值创建。 if _, err := os.Stat(configFile); os.IsNotExist(err) { log.Printf("Config file not found at %s, creating with defaults...", configFile) if err := os.MkdirAll(configDir, 0755); err != nil { log.Fatalf("Failed to create config directory %s: %v", configDir, err) } cfg := &Config{ Database: DatabaseConfig{ Type: "sqlite", DSN: mysqlExampleDSN, }, Web: WebConfig{ Port: defaultPort, Socket: getDefaultSocketPath(), }, Path: defaultPath, Secret: generateSecret(), } data, err := yaml.Marshal(cfg) if err != nil { log.Fatalf("Failed to marshal default config: %v", err) } if err := os.WriteFile(configFile, data, 0640); err != nil { log.Fatalf("Failed to write config file %s: %v", configFile, err) } // SECURITY_TODO #11:配置文件保存会话密钥;仅允许所有者读取 // (install_linux.sh 已应用 0640 权限)。 log.Printf("Default config created at %s", configFile) return cfg } // 读取现有配置文件。 data, err := os.ReadFile(configFile) if err != nil { log.Fatalf("Failed to read config file %s: %v", configFile, err) } cfg := &Config{} if err := yaml.Unmarshal(data, cfg); err != nil { log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err) } return applyDefaults(cfg, defaultPath, configFile) } // applyDefaults 以合理的默认值填充零值字段。 func applyDefaults(cfg *Config, defaultPath, configFile string) *Config { // 如果整个 web 块为空(旧配置中没有 "web" 键), // 填充默认端口,使应用仍能从 8080 启动。 if cfg.Web.Port == "" && cfg.Web.Socket == "" { cfg.Web.Port = defaultPort } if len(cfg.Web.TrustedProxies) == 0 { cfg.Web.TrustedProxies = defaultTrustedProxies } if cfg.Database.Type == "" { cfg.Database.Type = "sqlite" } if cfg.Path == "" { cfg.Path = defaultPath } if cfg.Secret == "" { // 配置文件存在但没有密钥。拒绝启动:静默生成的回退值要么 // 可预测(旧的 hostname+pid 方案),要么导致每次重启都使所有会话失效。 log.Fatalf("Config file %s is missing a session secret. "+ "Add a random value, e.g. `secret: %s`, and restart.", configFile, generateSecret()) } return cfg }