feat: 启动时自动补齐 config.yaml 缺失的配置项并回写文件
This commit is contained in:
@@ -56,6 +56,8 @@ go run .
|
||||
| Linux | `/etc/blog_go/config.yaml` | `/srv/blog_go/` |
|
||||
| Windows | `./win/etc/blog_go/config.yaml` | `./win/srv/blog_go/` |
|
||||
|
||||
已存在的配置文件若缺少配置项(例如升级前的旧文件没有 `database` 段),启动时会自动用默认值补齐并写回。
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
database:
|
||||
|
||||
+96
-15
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -54,8 +55,20 @@ var defaultTrustedProxies = []string{"127.0.0.1", "::1"}
|
||||
|
||||
const defaultPort = "8080"
|
||||
|
||||
// mysqlExampleDBName 作为示例写入新建的配置文件。
|
||||
const mysqlExampleDBName = "blog_go"
|
||||
// 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) {
|
||||
@@ -122,10 +135,10 @@ func LoadConfig(customPath string) *Config {
|
||||
Database: DatabaseConfig{
|
||||
Type: "sqlite",
|
||||
DBName: mysqlExampleDBName,
|
||||
Username: "user",
|
||||
Password: "password",
|
||||
Host: "127.0.0.1",
|
||||
Port: "3306",
|
||||
Username: mysqlExampleUser,
|
||||
Password: mysqlExamplePassword,
|
||||
Host: mysqlExampleHost,
|
||||
Port: mysqlExamplePort,
|
||||
},
|
||||
Web: WebConfig{
|
||||
Port: defaultPort,
|
||||
@@ -135,14 +148,7 @@ func LoadConfig(customPath string) *Config {
|
||||
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)
|
||||
}
|
||||
writeConfigFile(configFile, cfg)
|
||||
// SECURITY_TODO #11:配置文件保存会话密钥;仅允许所有者读取
|
||||
// (install_linux.sh 已应用 0640 权限)。
|
||||
log.Printf("Default config created at %s", configFile)
|
||||
@@ -158,9 +164,84 @@ func LoadConfig(customPath string) *Config {
|
||||
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)
|
||||
}
|
||||
|
||||
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 以合理的默认值填充零值字段。
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TestConfigFileCreatedNotWorldReadable 覆盖 SECURITY_TODO #11:
|
||||
@@ -34,3 +38,104 @@ func TestMySQLDSN(t *testing.T) {
|
||||
t.Fatalf("MySQLDSN() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigFillsMissingKeys 覆盖启动时补齐缺失配置项并回写文件:
|
||||
// 旧格式配置(无 database 段)应被补全,且不覆盖已存在的 path。
|
||||
func TestLoadConfigFillsMissingKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
secret := strings.Repeat("a", 64)
|
||||
old := "secret: " + secret + "\npath: ./data\n"
|
||||
if err := os.WriteFile(path, []byte(old), 0640); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg := LoadConfig(path)
|
||||
|
||||
if cfg.Database.Type != "sqlite" || cfg.Database.DBName != mysqlExampleDBName ||
|
||||
cfg.Database.Username != mysqlExampleUser || cfg.Database.Password != mysqlExamplePassword ||
|
||||
cfg.Database.Host != mysqlExampleHost || cfg.Database.Port != mysqlExamplePort {
|
||||
t.Fatalf("database defaults not filled: %+v", cfg.Database)
|
||||
}
|
||||
if cfg.Web.Port != defaultPort {
|
||||
t.Fatalf("web port = %q, want %q", cfg.Web.Port, defaultPort)
|
||||
}
|
||||
if cfg.Path != "./data" {
|
||||
t.Fatalf("path = %q, want ./data (must not be overwritten)", cfg.Path)
|
||||
}
|
||||
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat config: %v", err)
|
||||
}
|
||||
if perm := st.Mode().Perm(); perm != 0640 {
|
||||
t.Fatalf("config perms = %v, want 0640", perm)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
back := &Config{}
|
||||
if err := yaml.Unmarshal(data, back); err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
if back.Database.DBName != mysqlExampleDBName {
|
||||
t.Fatalf("rewritten file db_name = %q, want %q", back.Database.DBName, mysqlExampleDBName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigKeepsCompleteFile 覆盖配置完整时不回写文件。
|
||||
func TestLoadConfigKeepsCompleteFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := "database:\n" +
|
||||
" type: mysql\n" +
|
||||
" db_name: myblog\n" +
|
||||
" username: root\n" +
|
||||
" password: p@ss\n" +
|
||||
" host: db.local\n" +
|
||||
" port: \"3307\"\n" +
|
||||
"web:\n" +
|
||||
" port: \"8080\"\n" +
|
||||
" socket: \"\"\n" +
|
||||
" trusted_proxies:\n" +
|
||||
" - 127.0.0.1\n" +
|
||||
" - ::1\n" +
|
||||
"path: ./data\n" +
|
||||
"secret: " + strings.Repeat("a", 64) + "\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0640); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg := LoadConfig(path)
|
||||
|
||||
if cfg.Database.Password != "p@ss" {
|
||||
t.Fatalf("password = %q, want p@ss (must not be overwritten)", cfg.Database.Password)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, []byte(content)) {
|
||||
t.Fatalf("complete config file was rewritten:\n%s", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigMalformedNoRewrite 覆盖解析失败的配置文件不被回写。
|
||||
func TestLoadConfigMalformedNoRewrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
// web.port 为序列不能解码到 string,yaml.Unmarshal 报错。
|
||||
content := "secret: " + strings.Repeat("a", 64) + "\nweb:\n port: [8080]\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0640); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
LoadConfig(path)
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, []byte(content)) {
|
||||
t.Fatalf("malformed config was rewritten:\n%s", after)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user