Maksim Totmin 54259e51b9 feat: CID-based routing rules engine with flexible match conditions
Routing rules:
- RouteRule model: 3 match fields (caller_id, dest, ingress_trunk regex),
  3 action types (node/pool/auto), time-based (days of week, time range)
- Engine router.go: CompiledRule with pre-compiled regex,
  PickNodeForCall with cascade (dead node → next rule), pickNodeFromPool
- Separate sync.RWMutex for Router — zero contention admin vs traffic
- Validate-before-save pattern: engine validates before JSON save
- Graceful degradation: corrupted rules skipped, no rules → PickNode()

Persistence:
- data/routing.json with atomic save (tmp→rename), config.Manager
- Auto-created empty on first start

API (admin only):
- GET/POST/PUT/DELETE /api/routing/rules
- PUT /api/routing/rules/:id/toggle
- PUT /api/routing/rules/reorder

Integration:
- /api/route now uses PickNodeForCall with caller_id/dest/ingress_trunk
  query params (already sent by route.lua)
- RouteResponse includes matched_rule field for debugging
- Main.go loads routing rules on startup with graceful fallback

Web UI:
- /routing page: table with priority arrows, inline edit, create form,
  regex test tool, toggle, delete, action badges, hits counter

Tests:
- 28 tests: matchRule, time matching, PickNodeForCall cascade,
  pool scoring, validation, hit counting, compilation
2026-06-25 21:51:37 +07:00

491 lines
16 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"`
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)
}
}
if _, err := os.Stat(m.routingRulesPath()); os.IsNotExist(err) {
if err := m.saveFile(m.routingRulesPath(), []models.RouteRule{}); err != nil {
return nil, fmt.Errorf("создание routing.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)
}
// ReadRoutingRules читает routing.json. Потокобезопасно.
func (m *Manager) ReadRoutingRules() ([]models.RouteRule, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.readRoutingRulesLocked()
}
func (m *Manager) readRoutingRulesLocked() ([]models.RouteRule, error) {
data, err := os.ReadFile(m.routingRulesPath())
if err != nil {
return nil, fmt.Errorf("чтение routing.json: %w", err)
}
var rules []models.RouteRule
if err := json.Unmarshal(data, &rules); err != nil {
return nil, fmt.Errorf("парсинг routing.json: %w", err)
}
return rules, nil
}
// SaveRoutingRules атомарно сохраняет routing.json.
func (m *Manager) SaveRoutingRules(rules []models.RouteRule) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.saveRoutingRulesLocked(rules)
}
func (m *Manager) saveRoutingRulesLocked(rules []models.RouteRule) error {
data, err := json.MarshalIndent(rules, "", " ")
if err != nil {
return fmt.Errorf("сериализация routing.json: %w", err)
}
return m.saveFile(m.routingRulesPath(), 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") }
func (m *Manager) routingRulesPath() string { return filepath.Join(m.dir, "routing.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
}