Files
usernode/internal/config/config.go
T

273 lines
8.9 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 负责加载 TOML 配置文件并支持环境变量覆盖。
//
// 覆盖规则:环境变量名 = USERNODE_<SECTION>_<FIELD>(字段名 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"`
Auth AuthConfig `toml:"auth"`
SMTP SMTPConfig `toml:"smtp"`
System SystemConfig `toml:"system"`
Audit AuditConfig `toml:"audit"`
}
type AppConfig struct {
Name string `toml:"name"`
Env string `toml:"env"` // development / production
BaseURL string `toml:"base_url"` // 对外访问地址(邮件中的链接使用)
}
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 发送冷却
}
// AuthConfig 认证与防暴力参数。
type AuthConfig struct {
MaxLoginFailures int `toml:"max_login_failures"` // 管理员登录连续失败阈值,达到后锁定
LockDuration time.Duration `toml:"lock_duration"` // 失败达到阈值后的锁定时长
CaptchaTTL time.Duration `toml:"captcha_ttl"` // 图形验证码有效期
}
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 执行系统命令(生产)
DryRun bool `toml:"dry_run"` // true = 只打印计划命令不执行(开发演练);false 且 sudo=false 时直接执行(容器/测试用户验证)
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 所在目录(测试可覆盖)
}
// AuditConfig 审计保留与归档配置。
type AuditConfig struct {
ArchiveDir string `toml:"archive_dir"` // 每日归档目录(空 = 不归档也不自动清理,防止丢审计)
}
// Default 返回带开发环境默认值的配置,作为 config.example.toml 与未配置项的兜底。
func Default() *Config {
return &Config{
App: AppConfig{Name: "ws_usernode", Env: "development", BaseURL: "http://127.0.0.1:8080"},
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,
},
Auth: AuthConfig{
MaxLoginFailures: 5,
LockDuration: 15 * time.Minute,
CaptchaTTL: 5 * time.Minute,
},
SMTP: SMTPConfig{Port: 587},
Audit: AuditConfig{
// 开发默认不归档(避免在任意目录落文件);生产显式配置 archive_dir 启用归档。
ArchiveDir: "",
},
System: SystemConfig{
// 开发默认 dry-run:未配置 config 直接跑 serve 时只打印计划,避免误操作系统账号。
// 生产必须显式 dry_run=false 且 sudo=true(见 deploy/sudoers.example)。
Sudo: false,
DryRun: true,
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_<SECTION>_<FIELD> 覆盖。
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_SNAKESessionTTL → SESSION_TTL
// OTPTTL → OTP_TTLTrustedProxies → 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()
}