package main import ( "bytes" "fmt" "log" "os" "path/filepath" "time" "gopkg.in/yaml.v3" ) type Config struct { SMTP SMTPConfig `yaml:"smtp"` Web WebConfig `yaml:"web"` } type SMTPConfig struct { ListenAddress string `yaml:"listen_address"` ListenPort int `yaml:"listen_port"` StorageDir string `yaml:"storage_dir"` DomainName string `yaml:"domain_name"` MaxMessageSize int64 `yaml:"max_message_size"` EnableAuth bool `yaml:"enable_auth"` } type WebConfig struct { Enabled bool `yaml:"enabled"` Port int `yaml:"port"` Directory string `yaml:"directory"` Auth AuthConfig `yaml:"auth"` } type AuthConfig struct { Enabled bool `yaml:"enabled"` Username string `yaml:"username"` Password string `yaml:"password"` SessionTimeout string `yaml:"session_timeout"` CookieSecure bool `yaml:"cookie_secure"` LoginMaxAttempts int `yaml:"login_max_attempts"` LoginWindow string `yaml:"login_window"` LoginLockout string `yaml:"login_lockout"` } func defaultConfig() *Config { return &Config{ SMTP: SMTPConfig{ ListenAddress: "0.0.0.0", ListenPort: 25, StorageDir: "emails", DomainName: "m.ric930.ru", MaxMessageSize: 50 * 1024 * 1024, EnableAuth: true, }, Web: WebConfig{ Enabled: true, Port: 8089, Directory: "web", Auth: AuthConfig{ Enabled: true, Username: "admin", Password: "admin", SessionTimeout: "15m", CookieSecure: false, LoginMaxAttempts: 3, LoginWindow: "5m", LoginLockout: "15m", }, }, } } func getExecutableDir() (string, error) { exe, err := os.Executable() if err != nil { return "", err } exe, err = filepath.Abs(exe) if err != nil { return "", err } return filepath.Dir(exe), nil } func resolveConfigPaths( cfg *Config, exeDir string, ) { if cfg.SMTP.StorageDir != "" && !filepath.IsAbs(cfg.SMTP.StorageDir) { cfg.SMTP.StorageDir = filepath.Join( exeDir, cfg.SMTP.StorageDir, ) } if cfg.Web.Directory != "" && !filepath.IsAbs(cfg.Web.Directory) { cfg.Web.Directory = filepath.Join( exeDir, cfg.Web.Directory, ) } } func validateConfig(cfg *Config) error { if cfg.SMTP.ListenPort < 1 || cfg.SMTP.ListenPort > 65535 { return fmt.Errorf( "smtp.listen_port must be between 1 and 65535", ) } if cfg.SMTP.StorageDir == "" { return fmt.Errorf( "smtp.storage_dir must not be empty", ) } if cfg.SMTP.DomainName == "" { return fmt.Errorf( "smtp.domain_name must not be empty", ) } if cfg.SMTP.MaxMessageSize <= 0 { return fmt.Errorf( "smtp.max_message_size must be greater than zero", ) } if !cfg.Web.Enabled { return nil } if cfg.Web.Port < 1 || cfg.Web.Port > 65535 { return fmt.Errorf( "web.port must be between 1 and 65535", ) } if cfg.Web.Directory == "" { return fmt.Errorf( "web.directory must not be empty", ) } if !cfg.Web.Auth.Enabled { return nil } if cfg.Web.Auth.Username == "" { return fmt.Errorf( "web.auth.username must not be empty", ) } if cfg.Web.Auth.Password == "" { return fmt.Errorf( "web.auth.password must not be empty", ) } if cfg.Web.Auth.SessionTimeout == "" { return fmt.Errorf( "web.auth.session_timeout must not be empty", ) } sessionTimeout, err := time.ParseDuration( cfg.Web.Auth.SessionTimeout, ) if err != nil { return fmt.Errorf( "invalid web.auth.session_timeout %q: %w", cfg.Web.Auth.SessionTimeout, err, ) } if sessionTimeout <= 0 { return fmt.Errorf( "web.auth.session_timeout must be greater than zero", ) } if cfg.Web.Auth.LoginMaxAttempts <= 0 { return fmt.Errorf( "web.auth.login_max_attempts must be greater than zero", ) } loginWindow, err := time.ParseDuration( cfg.Web.Auth.LoginWindow, ) if err != nil { return fmt.Errorf( "invalid web.auth.login_window %q: %w", cfg.Web.Auth.LoginWindow, err, ) } if loginWindow <= 0 { return fmt.Errorf( "web.auth.login_window must be greater than zero", ) } loginLockout, err := time.ParseDuration( cfg.Web.Auth.LoginLockout, ) if err != nil { return fmt.Errorf( "invalid web.auth.login_lockout %q: %w", cfg.Web.Auth.LoginLockout, err, ) } if loginLockout <= 0 { return fmt.Errorf( "web.auth.login_lockout must be greater than zero", ) } return nil } func LoadConfig( configPath string, ) (*Config, error) { exeDir, err := getExecutableDir() if err != nil { log.Printf( "Warning: cannot get executable directory: %v, using current directory", err, ) exeDir = "." } if configPath == "" { configPath = "config.yaml" } finalConfigPath := configPath if !filepath.IsAbs(finalConfigPath) { finalConfigPath = filepath.Join( exeDir, finalConfigPath, ) } finalConfigPath, err = filepath.Abs( finalConfigPath, ) if err != nil { return nil, fmt.Errorf( "failed to resolve config path: %w", err, ) } log.Printf( "Loading config from %s", finalConfigPath, ) data, err := os.ReadFile( finalConfigPath, ) if err != nil { if !os.IsNotExist(err) { return nil, fmt.Errorf( "failed to read config %s: %w", finalConfigPath, err, ) } // Конфига нет вообще: // создаём новый default config. cfg := defaultConfig() if err := validateConfig(cfg); err != nil { return nil, fmt.Errorf( "default config is invalid: %w", err, ) } cfgData, err := yaml.Marshal(cfg) if err != nil { return nil, fmt.Errorf( "failed to encode default config: %w", err, ) } configDir := filepath.Dir( finalConfigPath, ) if err := os.MkdirAll( configDir, 0755, ); err != nil { return nil, fmt.Errorf( "failed to create config directory %s: %w", configDir, err, ) } if err := os.WriteFile( finalConfigPath, cfgData, 0600, ); err != nil { return nil, fmt.Errorf( "failed to create default config %s: %w", finalConfigPath, err, ) } log.Printf( "Created default config at %s", finalConfigPath, ) resolveConfigPaths( cfg, exeDir, ) return cfg, nil } /* ВАЖНО: Если config.yaml существует, НЕ используем defaultConfig() как основу. Иначе ошибочное/отсутствующее поле может незаметно получить значение, зашитое в бинарник. */ var cfg Config decoder := yaml.NewDecoder( bytes.NewReader(data), ) /* Запрещаем неизвестные поля. Например старое: basic_auth: вместо: auth: теперь вызовет ошибку при запуске. */ decoder.KnownFields(true) if err := decoder.Decode(&cfg); err != nil { return nil, fmt.Errorf( "failed to parse YAML config %s: %w", finalConfigPath, err, ) } if err := validateConfig(&cfg); err != nil { return nil, fmt.Errorf( "invalid config %s: %w", finalConfigPath, err, ) } log.Printf( "Config loaded: web_auth=%v username=%q session_timeout=%s", cfg.Web.Auth.Enabled, cfg.Web.Auth.Username, cfg.Web.Auth.SessionTimeout, ) resolveConfigPaths( &cfg, exeDir, ) return &cfg, nil }