414 lines
7.2 KiB
Go
414 lines
7.2 KiB
Go
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
|
|
}
|