// Package config управляет JSON-конфигами приложения: config.json, trunks.json, users.json. // Обеспечивает атомарную запись (tmp → rename) и thread-safe доступ через sync.RWMutex. package config import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "math" "os" "path/filepath" "strings" "sync" "time" "github.com/pulse-lets-go/internal/models" ) // ScoringWeights — веса компонентов скора. type ScoringWeights struct { CallScore float64 `json:"call_score"` LoadScore float64 `json:"load_score"` IdleScore float64 `json:"idle_score"` FailScore float64 `json:"fail_score"` } // ScoringConfig — пороги и веса для scoring engine. type ScoringConfig struct { IdleCPUMin float64 `json:"idle_cpu_min"` CallFailureRateLethal float64 `json:"call_failure_rate_lethal"` Weights ScoringWeights `json:"weights"` SmoothingFactor float64 `json:"smoothing_factor"` // 0 = без сглаживания, 0.3 = рекомендуемый EWMA LoadAvgMultiplier float64 `json:"load_avg_multiplier"` // множитель load_avg → score (стандартный 50) } // TLSConfig — настройки HTTPS. type TLSConfig struct { Enabled bool `json:"enabled"` CertFile string `json:"cert_file"` KeyFile string `json:"key_file"` } // RateLimitConfig — ограничения частоты запросов. type RateLimitConfig struct { Enabled bool `json:"enabled"` RoutePerSec int `json:"route_per_sec"` APIPerSec int `json:"api_per_sec"` } // LogConfig — настройки логирования. type LogConfig struct { Level string `json:"level"` // debug, info, warn, error Format string `json:"format"` // text, json } // ESLConfig — настройки подключения к FreeSWITCH ESL. type ESLConfig struct { Host string `json:"host"` // если пустой — ESL не запускается Port int `json:"port"` // порт ESL (по умолчанию 8021) Password string `json:"password"` PasswordEnv string `json:"password_env"` // имя переменной окружения с паролем SofiaProfile string `json:"sofia_profile"` // профиль Sofia (external/internal) GatewayPrefix string `json:"gateway_prefix"` // префикс gateway (pulse) } // GetPassword возвращает ESL-пароль: из переменной окружения, если задан password_env, иначе из поля password. func (e *ESLConfig) GetPassword() string { if e.PasswordEnv != "" { if val := os.Getenv(e.PasswordEnv); val != "" { return val } } return e.Password } // GetJWTSecret возвращает JWT-секрет: из переменной окружения, если задан jwt_secret_env, иначе из поля jwt_secret. func (c *Config) GetJWTSecret() string { if c.JWTSecretEnv != "" { if val := os.Getenv(c.JWTSecretEnv); val != "" { return val } } return c.JWTSecret } // Config — корневая конфигурация приложения (config.json). type Config struct { NatsURL string `json:"nats_url"` NatsUser string `json:"nats_user"` NatsPassword string `json:"nats_password"` ListenAddr string `json:"listen_addr"` JWTSecret string `json:"jwt_secret"` JWTSecretEnv string `json:"jwt_secret_env"` // имя переменной окружения с JWT-секретом MonitoringAPIKey string `json:"monitoring_api_key"` StaleThresholdSec int `json:"stale_threshold_sec"` Scoring ScoringConfig `json:"scoring"` TLS TLSConfig `json:"tls"` RateLimit RateLimitConfig `json:"rate_limit"` Log LogConfig `json:"log"` ESL ESLConfig `json:"esl"` BalanceMode string `json:"balance_mode"` // "best" | "weighted_random" } // Validate проверяет корректность всех полей конфига. // Возвращает читаемое описание всех найденных ошибок. func (c *Config) Validate() error { var errs []string if c.NatsURL == "" { errs = append(errs, "nats_url обязателен") } if c.ListenAddr == "" { errs = append(errs, "listen_addr обязателен") } if c.StaleThresholdSec <= 0 { errs = append(errs, "stale_threshold_sec должен быть > 0") } if c.JWTSecret == "" { errs = append(errs, "jwt_secret обязателен") } if c.MonitoringAPIKey == "" { errs = append(errs, "monitoring_api_key обязателен") } if c.Scoring.IdleCPUMin < 0 { errs = append(errs, "scoring.idle_cpu_min должен быть >= 0") } if c.Scoring.CallFailureRateLethal < 0 || c.Scoring.CallFailureRateLethal > 100 { errs = append(errs, "scoring.call_failure_rate_lethal должен быть 0..100") } if c.Scoring.SmoothingFactor < 0 || c.Scoring.SmoothingFactor > 1 { errs = append(errs, "scoring.smoothing_factor должен быть 0..1") } if c.Scoring.LoadAvgMultiplier < 0 { errs = append(errs, "scoring.load_avg_multiplier должен быть >= 0") } w := &c.Scoring.Weights sum := w.CallScore + w.LoadScore + w.IdleScore + w.FailScore if math.Abs(sum-1.0) > 0.01 { errs = append(errs, fmt.Sprintf("scoring.weights сумма должна быть ≈1.0 (сейчас %.2f)", sum)) } if c.TLS.Enabled { if c.TLS.CertFile == "" || c.TLS.KeyFile == "" { errs = append(errs, "tls.cert_file и tls.key_file обязательны при tls.enabled=true") } } if c.RateLimit.Enabled { if c.RateLimit.RoutePerSec <= 0 { errs = append(errs, "rate_limit.route_per_sec должен быть > 0") } if c.RateLimit.APIPerSec <= 0 { errs = append(errs, "rate_limit.api_per_sec должен быть > 0") } } switch c.Log.Level { case "debug", "info", "warn", "error", "": default: errs = append(errs, "log.level должен быть debug/info/warn/error") } switch c.Log.Format { case "text", "json", "": default: errs = append(errs, "log.format должен быть text/json") } // Balance mode validation switch c.BalanceMode { case "best", "weighted_random", "": default: errs = append(errs, "balance_mode должен быть best или weighted_random") } // ESL validation if c.ESL.Host != "" { if c.ESL.Port <= 0 || c.ESL.Port > 65535 { errs = append(errs, "esl.port должен быть 1-65535") } if c.ESL.SofiaProfile == "" { errs = append(errs, "esl.sofia_profile обязателен при esl.host") } if c.ESL.Password == "" && c.ESL.PasswordEnv == "" { errs = append(errs, "esl.password или esl.password_env обязателен при esl.host") } } if len(errs) > 0 { return fmt.Errorf("ошибки конфигурации:\n - %s", strings.Join(errs, "\n - ")) } return nil } // Manager управляет чтением и атомарной записью JSON-конфигов. type Manager struct { mu sync.RWMutex dir string // путь к data/ } // NewManager создаёт новый менеджер конфигов из директории dataDir. // Если файлы отсутствуют — создаёт их с дефолтными значениями. func NewManager(dataDir string) (*Manager, error) { // Убеждаемся, что директория существует if err := os.MkdirAll(dataDir, 0750); err != nil { return nil, fmt.Errorf("создание data/: %w", err) } m := &Manager{dir: dataDir} // Инициализируем конфиги при первом запуске if _, err := os.Stat(m.configPath()); os.IsNotExist(err) { if err := m.createDefaultConfig(); err != nil { return nil, fmt.Errorf("создание config.json: %w", err) } } if _, err := os.Stat(m.trunksPath()); os.IsNotExist(err) { if err := m.saveFile(m.trunksPath(), []models.Trunk{}); err != nil { return nil, fmt.Errorf("создание trunks.json: %w", err) } } if _, err := os.Stat(m.usersPath()); os.IsNotExist(err) { if err := m.createDefaultUsers(); err != nil { return nil, fmt.Errorf("создание users.json: %w", err) } } return m, nil } // ReadConfig читает config.json. Потокобезопасно. func (m *Manager) ReadConfig() (*Config, error) { m.mu.RLock() defer m.mu.RUnlock() data, err := os.ReadFile(m.configPath()) if err != nil { return nil, fmt.Errorf("чтение config.json: %w", err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("парсинг config.json: %w", err) } return &cfg, nil } // SaveConfig атомарно сохраняет config.json. func (m *Manager) SaveConfig(cfg *Config) error { m.mu.Lock() defer m.mu.Unlock() data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("сериализация config.json: %w", err) } return m.saveFile(m.configPath(), data) } // ReadTrunks читает trunks.json. Потокобезопасно. func (m *Manager) ReadTrunks() ([]models.Trunk, error) { m.mu.RLock() defer m.mu.RUnlock() return m.readTrunksLocked() } // readTrunksLocked — внутреннее чтение без блокировки (вызывающий держит lock). func (m *Manager) readTrunksLocked() ([]models.Trunk, error) { data, err := os.ReadFile(m.trunksPath()) if err != nil { return nil, fmt.Errorf("чтение trunks.json: %w", err) } var trunks []models.Trunk if err := json.Unmarshal(data, &trunks); err != nil { return nil, fmt.Errorf("парсинг trunks.json: %w", err) } return trunks, nil } // SaveTrunks атомарно сохраняет trunks.json. func (m *Manager) SaveTrunks(trunks []models.Trunk) error { m.mu.Lock() defer m.mu.Unlock() return m.saveTrunksLocked(trunks) } func (m *Manager) saveTrunksLocked(trunks []models.Trunk) error { data, err := json.MarshalIndent(trunks, "", " ") if err != nil { return fmt.Errorf("сериализация trunks.json: %w", err) } return m.saveFile(m.trunksPath(), data) } // ReadUsers читает users.json. Потокобезопасно. func (m *Manager) ReadUsers() ([]models.User, error) { m.mu.RLock() defer m.mu.RUnlock() return m.readUsersLocked() } func (m *Manager) readUsersLocked() ([]models.User, error) { data, err := os.ReadFile(m.usersPath()) if err != nil { return nil, fmt.Errorf("чтение users.json: %w", err) } var users []models.User if err := json.Unmarshal(data, &users); err != nil { return nil, fmt.Errorf("парсинг users.json: %w", err) } return users, nil } // SaveUsers атомарно сохраняет users.json. func (m *Manager) SaveUsers(users []models.User) error { m.mu.Lock() defer m.mu.Unlock() return m.saveUsersLocked(users) } func (m *Manager) saveUsersLocked(users []models.User) error { data, err := json.MarshalIndent(users, "", " ") if err != nil { return fmt.Errorf("сериализация users.json: %w", err) } return m.saveFile(m.usersPath(), data) } // --- Приватные методы --- func (m *Manager) configPath() string { return filepath.Join(m.dir, "config.json") } func (m *Manager) trunksPath() string { return filepath.Join(m.dir, "trunks.json") } func (m *Manager) usersPath() string { return filepath.Join(m.dir, "users.json") } // saveFile атомарно сохраняет данные в файл: tmp → rename. // Вызывающий держит write lock. func (m *Manager) saveFile(path string, data interface{}) error { var raw []byte var err error switch v := data.(type) { case []byte: raw = v default: raw, err = json.MarshalIndent(data, "", " ") if err != nil { return fmt.Errorf("сериализация: %w", err) } } tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, raw, 0640); err != nil { return fmt.Errorf("запись временного файла: %w", err) } if err := os.Rename(tmpPath, path); err != nil { os.Remove(tmpPath) // clean up return fmt.Errorf("атомарный rename: %w", err) } return nil } // createDefaultConfig генерирует config.json со значениями по умолчанию. func (m *Manager) createDefaultConfig() error { jwtSecret, err := generateRandomHex(32) if err != nil { return err } apiKey, err := generateRandomHex(32) if err != nil { return err } cfg := Config{ NatsURL: "nats://localhost:4222", NatsUser: "", NatsPassword: "", ListenAddr: ":8080", JWTSecret: jwtSecret, MonitoringAPIKey: apiKey, StaleThresholdSec: 20, Scoring: ScoringConfig{ IdleCPUMin: 5.0, CallFailureRateLethal: 15.0, Weights: ScoringWeights{ CallScore: 0.40, LoadScore: 0.30, IdleScore: 0.20, FailScore: 0.10, }, SmoothingFactor: 0.3, LoadAvgMultiplier: 50.0, }, TLS: TLSConfig{ Enabled: false, CertFile: "", KeyFile: "", }, RateLimit: RateLimitConfig{ Enabled: true, RoutePerSec: 5000, APIPerSec: 100, }, Log: LogConfig{ Level: "info", Format: "text", }, ESL: ESLConfig{ Host: "", Port: 8021, Password: "", PasswordEnv: "", SofiaProfile: "external", GatewayPrefix: "pulse", }, BalanceMode: "weighted_random", } return m.SaveConfig(&cfg) } // createDefaultUsers создаёт users.json с дефолтным админом (admin/admin). // Пароль хранится в bcrypt. На первом запуске запрашивается смена. func (m *Manager) createDefaultUsers() error { // Используем плейсхолдер-хэш "admin" — при первом логине админ обязан сменить пароль. // Хэш будет заменён на bcrypt при создании/обновлении через API. defaultTime := time.Now().UTC() users := []models.User{ { ID: "user-01", Username: "admin", Role: models.RoleAdmin, CreatedAt: defaultTime, UpdatedAt: defaultTime, // PasswordHash будет установлен при первом логине через API }, } return m.saveUsersLocked(users) } // generateRandomHex генерирует случайную hex-строку заданной длины в байтах. func generateRandomHex(bytes int) (string, error) { buf := make([]byte, bytes) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("генерация случайных байт: %w", err) } return hex.EncodeToString(buf), nil }