Files
go_blog/config/config.go
T

272 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"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"
DBName string `yaml:"db_name"` // 数据库名(type 为 "mysql" 时必填)
Username string `yaml:"username"` // 用户名(type 为 "mysql" 时必填)
Password string `yaml:"password"` // 密码(type 为 "mysql" 时必填)
Host string `yaml:"host"` // IP 或主机名(type 为 "mysql" 时必填)
Port string `yaml:"port"` // 端口(type 为 "mysql" 时必填)
}
// MySQLDSN 根据拆分字段构建 MySQL 连接字符串。
func (d *DatabaseConfig) MySQLDSN() string {
return fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
d.Username, d.Password, d.Host, d.Port, d.DBName,
)
}
// 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"
// MySQL 示例值,用于写入新建配置文件或补齐缺失的数据库段。
const (
mysqlExampleDBName = "blog_go"
mysqlExampleUser = "user"
mysqlExamplePassword = "password"
mysqlExampleHost = "127.0.0.1"
mysqlExamplePort = "3306"
)
// databaseKeys / webKeys 用于检查配置文件中缺失的子键。
var (
databaseKeys = []string{"type", "db_name", "username", "password", "host", "port"}
webKeys = []string{"port", "socket", "trusted_proxies"}
)
// 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",
DBName: mysqlExampleDBName,
Username: mysqlExampleUser,
Password: mysqlExamplePassword,
Host: mysqlExampleHost,
Port: mysqlExamplePort,
},
Web: WebConfig{
Port: defaultPort,
Socket: getDefaultSocketPath(),
},
Path: defaultPath,
Secret: generateSecret(),
}
writeConfigFile(configFile, cfg)
// 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)
}
cfg = applyDefaults(cfg, defaultPath, configFile)
// 检查配置文件里缺失的键(如旧版本没有 database 段),缺失项自动补全并回写。
if missing := missingConfigKeys(data); len(missing) > 0 {
fillDatabaseExamples(&cfg.Database)
writeConfigFile(configFile, cfg)
log.Printf("Config file %s was missing: %s. Added defaults.", configFile, strings.Join(missing, ", "))
}
return cfg
}
// writeConfigFile 以 0640 权限写入配置文件;已有文件保留原权限。
func writeConfigFile(configFile string, cfg *Config) {
data, err := yaml.Marshal(cfg)
if err != nil {
log.Fatalf("Failed to marshal config: %v", err)
}
if err := os.WriteFile(configFile, data, 0640); err != nil {
log.Fatalf("Failed to write config file %s: %v", configFile, err)
}
}
// missingConfigKeys 返回配置文件中缺失的键(含顶层与 database/web 子键)。
// 解析失败时不返回任何缺失(调用方已按 malformed 路径处理)。
func missingConfigKeys(data []byte) []string {
raw := map[string]any{}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil
}
var missing []string
if db, ok := raw["database"].(map[string]any); ok {
for _, k := range databaseKeys {
if _, ok := db[k]; !ok {
missing = append(missing, "database."+k)
}
}
} else {
missing = append(missing, "database")
}
if web, ok := raw["web"].(map[string]any); ok {
for _, k := range webKeys {
if _, ok := web[k]; !ok {
missing = append(missing, "web."+k)
}
}
} else {
missing = append(missing, "web")
}
if _, ok := raw["path"]; !ok {
missing = append(missing, "path")
}
return missing
}
// fillDatabaseExamples 用示例值填充数据库段中的空字段,用于补齐缺失配置后的回写。
func fillDatabaseExamples(d *DatabaseConfig) {
if d.Type == "" {
d.Type = "sqlite"
}
if d.DBName == "" {
d.DBName = mysqlExampleDBName
}
if d.Username == "" {
d.Username = mysqlExampleUser
}
if d.Password == "" {
d.Password = mysqlExamplePassword
}
if d.Host == "" {
d.Host = mysqlExampleHost
}
if d.Port == "" {
d.Port = mysqlExamplePort
}
}
// 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
}