Files
usernode/internal/config/config.go
T
cao.wangrenbo ae45aba607 feat(M0): 工程骨架 — Go 后端 + Vue3 前端脚手架
- Go 工程:cmd/usernode CLI(serve/migrate/admin create/reset-password/user otp)
  + internal/{config,model,service,system,auth,cron,api,router,server,pkg,webui}
- 配置:TOML + 环境变量覆盖(USERNODE_<SEC>_<FIELD>),config.example.toml
- 数据:GORM 双驱动(SQLite/MySQL)8 表模型,migrate 子命令可跑通
- 系统层:system.Manager 接口(useradd/userdel/passwd 白名单,dev dry-run)
- HTTP:Gin 路由骨架(/api/v1 + 501 占位),healthz,slog 结构化日志,优雅启停
- 前端:Vite + Vue3 + TS + Element Plus 最小可运行(web/),go:embed 打通
- 构建:deploy/Containerfile 多阶段单二进制镜像,web/Containerfile.dev 前端 dev
  镜像,Makefile(build/test/dev/web-build/image);go.mod 锁定 go 1.22
- 验证:go build/vet/test 通过;podman 镜像构建运行 healthz+embed 通过
2026-08-29 22:43:27 +08:00

245 lines
7.5 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"`
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_<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()
}