// Package config 负责加载 TOML 配置文件并支持环境变量覆盖。 // // 覆盖规则:环境变量名 = USERNODE_
_(字段名 camelCase 转 // SCREAMING_SNAKE),例如 server.listen 对应 USERNODE_SERVER_LISTEN, // database.driver 对应 USERNODE_DATABASE_DRIVER。仅当环境变量存在时覆盖, // 便于容器/CI 场景注入敏感配置(如 SMTP 密码)而不落地到文件。 package config import ( "errors" "fmt" "os" "reflect" "strconv" "strings" "time" "unicode" "github.com/BurntSushi/toml" ) const envPrefix = "USERNODE_" // Config 为全部配置的根。各子结构对应 config.example.toml 的一个 section。 type Config struct { App AppConfig `toml:"app"` Server ServerConfig `toml:"server"` Database DatabaseConfig `toml:"database"` Log LogConfig `toml:"log"` Policy PolicyConfig `toml:"policy"` SMTP SMTPConfig `toml:"smtp"` System SystemConfig `toml:"system"` } type AppConfig struct { Name string `toml:"name"` Env string `toml:"env"` // development / production } type ServerConfig struct { Listen string `toml:"listen"` // 监听地址,如 0.0.0.0:8080 SessionTTL time.Duration `toml:"session_ttl"` TrustedProxies []string `toml:"trusted_proxies"` } type DatabaseConfig struct { Driver string `toml:"driver"` // sqlite / mysql DSN string `toml:"dsn"` } type LogConfig struct { Level string `toml:"level"` // debug / info / warn / error Format string `toml:"format"` // text / json } // PolicyConfig 为账号生命周期与 OTP 策略的全局默认值,可在 settings 表按需覆盖(M4)。 type PolicyConfig struct { DefaultTTL time.Duration `toml:"default_ttl"` // 新账号默认有效期 RecyclePeriod time.Duration `toml:"recycle_period"` // 到期后回收期,期内可延期恢复 AuditRetention time.Duration `toml:"audit_retention"` // 审计保留时长 OTPTTL time.Duration `toml:"otp_ttl"` // OTP 验证码有效期 OTPCooldown time.Duration `toml:"otp_cooldown"` // OTP 发送冷却 } type SMTPConfig struct { Host string `toml:"host"` Port int `toml:"port"` Username string `toml:"username"` Password string `toml:"password"` From string `toml:"from"` } // SystemConfig 为系统账号操作层的本地实现配置(sudoers 白名单模式)。 type SystemConfig struct { Sudo bool `toml:"sudo"` // 是否通过 sudo -n 执行系统命令;开发环境 false 时 dry-run UserPrefix string `toml:"user_prefix"` // 外部用户系统账号前缀,默认 ext_ Group string `toml:"group"` // 外部用户所属组,默认 external Shell string `toml:"shell"` // 默认 shell HomeBase string `toml:"home_base"` // 家目录基路径 AuthorizedKeysDir string `toml:"authorized_keys_dir"` // authorized_keys 所在目录(测试可覆盖) } // Default 返回带开发环境默认值的配置,作为 config.example.toml 与未配置项的兜底。 func Default() *Config { return &Config{ App: AppConfig{Name: "ws_usernode", Env: "development"}, Server: ServerConfig{ Listen: "127.0.0.1:8080", SessionTTL: 24 * time.Hour, TrustedProxies: []string{"127.0.0.1", "::1"}, }, Database: DatabaseConfig{Driver: "sqlite", DSN: "data/usernode.db"}, Log: LogConfig{Level: "info", Format: "text"}, Policy: PolicyConfig{ DefaultTTL: 90 * 24 * time.Hour, RecyclePeriod: 30 * 24 * time.Hour, AuditRetention: 30 * 24 * time.Hour, OTPTTL: 10 * time.Minute, OTPCooldown: 60 * time.Second, }, SMTP: SMTPConfig{Port: 587}, System: SystemConfig{ Sudo: false, UserPrefix: "ext_", Group: "external", Shell: "/bin/sh", HomeBase: "/home", AuthorizedKeysDir: ".ssh", }, } } // Load 加载配置文件并以环境变量覆盖。path 为空时仅使用默认值 + 环境变量。 func Load(path string) (*Config, error) { cfg := Default() if path != "" { if _, err := toml.DecodeFile(path, cfg); err != nil { return nil, fmt.Errorf("load config %s: %w", path, err) } } if err := applyEnvOverrides(cfg); err != nil { return nil, err } if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } // LoadDefault 仅用于测试或未指定配置文件时的最小加载。 func LoadDefault() (*Config, error) { return Load("") } func (c *Config) validate() error { if c.App.Env == "" { c.App.Env = "development" } switch c.Database.Driver { case "sqlite", "mysql": default: return fmt.Errorf("config: unsupported database driver %q (want sqlite or mysql)", c.Database.Driver) } if c.Database.DSN == "" { return errors.New("config: database.dsn is required") } if c.System.UserPrefix == "" { return errors.New("config: system.user_prefix must not be empty") } if c.System.Group == "" { return errors.New("config: system.group must not be empty") } return nil } // applyEnvOverrides 通过反射遍历各 section,按 USERNODE_
_ 覆盖。 func applyEnvOverrides(cfg *Config) error { v := reflect.ValueOf(cfg).Elem() t := v.Type() for i := 0; i < t.NumField(); i++ { sec := t.Field(i) secVal := v.Field(i) if secVal.Kind() != reflect.Struct { continue } prefix := envPrefix + strings.ToUpper(sec.Name) + "_" for j := 0; j < secVal.Type().NumField(); j++ { f := secVal.Type().Field(j) envKey := prefix + strings.ToUpper(camelToSnake(f.Name)) if val, ok := os.LookupEnv(envKey); ok { if err := setField(secVal.Field(j), val); err != nil { return fmt.Errorf("config: env %s: %w", envKey, err) } } } } return nil } // setField 按字段类型解析环境变量字符串。 func setField(f reflect.Value, val string) error { switch f.Kind() { case reflect.String: f.SetString(val) case reflect.Bool: b, err := strconv.ParseBool(val) if err != nil { return err } f.SetBool(b) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: // time.Duration 的底层是 int64,需先尝试 ParseDuration if _, ok := f.Interface().(time.Duration); ok { d, err := time.ParseDuration(val) if err != nil { return err } f.SetInt(int64(d)) return nil } n, err := strconv.ParseInt(val, 10, 64) if err != nil { return err } f.SetInt(n) case reflect.Slice: // 逗号分隔,如 USERNODE_SERVER_TRUSTED_PROXIES=1.2.3.4,5.6.7.8 if f.Type().Elem().Kind() != reflect.String { return fmt.Errorf("unsupported slice element type %v", f.Type().Elem()) } parts := strings.Split(val, ",") out := reflect.MakeSlice(f.Type(), 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = reflect.Append(out, reflect.ValueOf(p)) } } f.Set(out) default: return fmt.Errorf("unsupported field type %v", f.Type()) } return nil } // camelToSnake 将 camelCase 转为 SCREAMING_SNAKE(SessionTTL → SESSION_TTL, // OTPTTL → OTP_TTL,TrustedProxies → TRUSTED_PROXIES)。 func camelToSnake(s string) string { var b strings.Builder runes := []rune(s) for i, r := range runes { if unicode.IsUpper(r) { // 单词边界:前一个字符是小写/数字,或当前大写且其后是小写(连续大写结尾) if i > 0 { prev := runes[i-1] nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1]) if unicode.IsLower(prev) || unicode.IsDigit(prev) || nextLower && unicode.IsUpper(prev) { b.WriteByte('_') } } b.WriteRune(unicode.ToLower(r)) } else { b.WriteRune(r) } } return b.String() }