2026-06-25 14:27:41 +07:00

421 lines
13 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 управляет 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"`
}
// 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
}
// 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"`
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"`
}
// 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")
}
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")
}
// 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,
},
},
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",
},
}
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
}