273 lines
8.9 KiB
Go
273 lines
8.9 KiB
Go
// 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_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()
|
||
}
|