421 lines
13 KiB
Go
421 lines
13 KiB
Go
// 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
|
||
}
|