refactor: common util package, ESL/AMI/security fixes, Prometheus metrics

Backend stability and security improvements:

* internal/util/ — common RandomHex helper, removed 3 duplicates
* ESL: deduplicated readMessage (locked/unlocked), net.JoinHostPort for IPv6
* AMI: synchronous reconnect() in readEventsLoop, net.JoinHostPort for IPv6
* Auth: /api/auth/refresh accepts Authorization header only (no ?token=)
* decodeJSON: http.MaxBytesReader(1<<20) body limit
* Trunks: gatewayParams() uses configured ESL.GatewayPrefix
* Config: jwt_secret_env env-var fallback
* FSCollector: time.After → time.NewTimer with defer Stop
* Monitoring: Prometheus counters (route_requests, nodes_total/healthy, uptime)
* go fmt pass across all internal/ packages
This commit is contained in:
Maksim Totmin 2026-06-25 19:30:36 +07:00
parent e66ac27dd9
commit cc9da3ad7d
25 changed files with 261 additions and 237 deletions

View File

@ -49,7 +49,7 @@ type valueSpec struct {
isRand bool isRand bool
} }
func fixed(v float64) valueSpec { return valueSpec{fixed: v} } func fixed(v float64) valueSpec { return valueSpec{fixed: v} }
func rrange(min, max float64) valueSpec { func rrange(min, max float64) valueSpec {
return valueSpec{min: min, max: max, isRand: true} return valueSpec{min: min, max: max, isRand: true}
} }
@ -71,7 +71,7 @@ type nodeSpec struct {
idleCPU valueSpec idleCPU valueSpec
loadAvg valueSpec loadAvg valueSpec
failRate valueSpec failRate valueSpec
sipGateway string // SIP-адрес для авто-создания транка sipGateway string // SIP-адрес для авто-создания транка
} }
func (ns *nodeSpec) generate() nodeMetric { func (ns *nodeSpec) generate() nodeMetric {

View File

@ -32,12 +32,15 @@ func (fc *FSCollector) Connect() error {
fc.client.ConnectWithRetry() fc.client.ConnectWithRetry()
// Ждём подключения с таймаутом // Ждём подключения с таймаутом
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
for !fc.client.IsConnected() { for !fc.client.IsConnected() {
timer.Reset(100 * time.Millisecond)
select { select {
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("esl connect timeout") return fmt.Errorf("esl connect timeout")
case <-time.After(100 * time.Millisecond): case <-timer.C:
// Выходим из цикла после ConnectWithRetry
return nil return nil
} }
} }

View File

@ -27,18 +27,18 @@ import (
// Config — конфигурация агента (agent.json). // Config — конфигурация агента (agent.json).
type Config struct { type Config struct {
NodeID string `json:"node_id"` // уникальный ID ноды (uc06, ses-sip) NodeID string `json:"node_id"` // уникальный ID ноды (uc06, ses-sip)
Type string `json:"type"` // "freeswitch" или "asterisk" Type string `json:"type"` // "freeswitch" или "asterisk"
NatsURL string `json:"nats_url"` // NATS URL NatsURL string `json:"nats_url"` // NATS URL
NatsUser string `json:"nats_user,omitempty"` NatsUser string `json:"nats_user,omitempty"`
NatsPassword string `json:"nats_password,omitempty"` NatsPassword string `json:"nats_password,omitempty"`
IntervalSec int `json:"interval_sec"` // интервал сбора (default: 5) IntervalSec int `json:"interval_sec"` // интервал сбора (default: 5)
MaxCalls int `json:"max_calls"` // ёмкость ноды (default: 250) MaxCalls int `json:"max_calls"` // ёмкость ноды (default: 250)
SIPGateway string `json:"sip_gateway"` // SIP-адрес для auto-транка SIPGateway string `json:"sip_gateway"` // SIP-адрес для auto-транка
SIPGatewayAuto bool `json:"sip_gateway_auto"` // авто-определить из PBX SIPGatewayAuto bool `json:"sip_gateway_auto"` // авто-определить из PBX
FailureWindow int `json:"failure_window"` // окно для call_failure_rate (default: 1000) FailureWindow int `json:"failure_window"` // окно для call_failure_rate (default: 1000)
ESL ESLCfg `json:"esl,omitempty"` ESL ESLCfg `json:"esl,omitempty"`
AMI AMICfg `json:"ami,omitempty"` AMI AMICfg `json:"ami,omitempty"`
} }
// ESLCfg — настройки подключения к FreeSWITCH ESL. // ESLCfg — настройки подключения к FreeSWITCH ESL.

View File

@ -12,8 +12,8 @@ import (
// Publisher публикует метрики в NATS. // Publisher публикует метрики в NATS.
type Publisher struct { type Publisher struct {
nc *natsgo.Conn nc *natsgo.Conn
nodeID string nodeID string
} }
// NewPublisher создаёт NATS publisher. // NewPublisher создаёт NATS publisher.

View File

@ -15,9 +15,9 @@ func nowTS() int64 {
// SystemStats содержит системные метрики (CPU, load). // SystemStats содержит системные метрики (CPU, load).
type SystemStats struct { type SystemStats struct {
Load1 float64 // load average 1m Load1 float64 // load average 1m
Load5 float64 // load average 5m Load5 float64 // load average 5m
Load15 float64 // load average 15m Load15 float64 // load average 15m
IdleCPU float64 // процент простоя CPU IdleCPU float64 // процент простоя CPU
} }

View File

@ -1,16 +1,16 @@
// SIP тестер для e2e проверки pulse-lets-go + FreeSWITCH. // SIP тестер для e2e проверки pulse-lets-go + FreeSWITCH.
// Два режима: // Два режима:
// uas — отвечает 200 OK на SIP INVITE, симулирует PBX-ноду //
// uac — шлёт SIP INVITE в FreeSWITCH, симулирует оператора // uas — отвечает 200 OK на SIP INVITE, симулирует PBX-ноду
// uac — шлёт SIP INVITE в FreeSWITCH, симулирует оператора
// //
// Примеры: // Примеры:
// ./siptest -mode uas (PBX-нода на порту 5090) //
// ./siptest -mode uac -r 10 -l 100 (оператор, 10 CPS, до 100 одновременных) // ./siptest -mode uas (PBX-нода на порту 5090)
// ./siptest -mode uac -r 10 -l 100 (оператор, 10 CPS, до 100 одновременных)
package main package main
import ( import (
"crypto/rand"
"encoding/hex"
"flag" "flag"
"fmt" "fmt"
"log" "log"
@ -19,6 +19,8 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/pulse-lets-go/internal/util"
) )
const ( const (
@ -29,9 +31,9 @@ const (
// Статистика // Статистика
var ( var (
callsSent atomic.Int64 callsSent atomic.Int64
callsOK atomic.Int64 callsOK atomic.Int64
callsFailed atomic.Int64 callsFailed atomic.Int64
) )
func main() { func main() {
@ -103,7 +105,7 @@ func buildSIPResponse(invite, localAddr string) string {
// Добавляем tag к To если его нет // Добавляем tag к To если его нет
to = strings.TrimSpace(to) to = strings.TrimSpace(to)
if !strings.Contains(to, ";tag=") { if !strings.Contains(to, ";tag=") {
to += ";tag=uas-" + randomHex(4) to += ";tag=uas-" + util.RandomHex(4)
} }
return fmt.Sprintf("SIP/2.0 200 OK\r\n"+ return fmt.Sprintf("SIP/2.0 200 OK\r\n"+
@ -213,8 +215,8 @@ func runUAC(rate, limit, maxCalls int, dest, caller, host string) {
} }
func buildSIPInvite(dest, caller string, callNum int, srcAddr, fsAddr string) string { func buildSIPInvite(dest, caller string, callNum int, srcAddr, fsAddr string) string {
callID := fmt.Sprintf("call-%d-%s@%s", callNum, randomHex(4), "127.0.0.1") callID := fmt.Sprintf("call-%d-%s@%s", callNum, util.RandomHex(4), "127.0.0.1")
branch := "z9hG4bK-" + randomHex(8) branch := "z9hG4bK-" + util.RandomHex(8)
return fmt.Sprintf("INVITE sip:%s@%s SIP/2.0\r\n"+ return fmt.Sprintf("INVITE sip:%s@%s SIP/2.0\r\n"+
"Via: SIP/2.0/UDP %s;branch=%s\r\n"+ "Via: SIP/2.0/UDP %s;branch=%s\r\n"+
@ -227,9 +229,3 @@ func buildSIPInvite(dest, caller string, callNum int, srcAddr, fsAddr string) st
"\r\n", "\r\n",
dest, fsAddr, srcAddr, branch, caller, callNum, dest, callID, caller, srcAddr) dest, fsAddr, srcAddr, branch, caller, callNum, dest, callID, caller, srcAddr)
} }
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)[:n]
}

View File

@ -63,7 +63,7 @@ func NewClient(host string, port int, username, password string) *Client {
func (c *Client) Connect() error { func (c *Client) Connect() error {
c.shouldReconn.Store(true) c.shouldReconn.Store(true)
addr := fmt.Sprintf("%s:%d", c.host, c.port) addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port))
dialer := net.Dialer{Timeout: 5 * time.Second} dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.Dial("tcp", addr) conn, err := dialer.Dial("tcp", addr)
if err != nil { if err != nil {
@ -268,11 +268,9 @@ func (c *Client) readEventsLoop() {
if err != nil { if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
c.connected.Store(false) c.connected.Store(false)
log.Printf("[ami] соединение разорвано") log.Printf("[ami] соединение разорвано, реконнект...")
if c.shouldReconn.Load() { c.reconnect()
go c.ConnectWithRetry() continue
}
return
} }
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
continue continue
@ -291,3 +289,29 @@ func (c *Client) readEventsLoop() {
} }
} }
} }
// reconnect выполняет реконнект с backoff (без горутин — синхронный вызов).
func (c *Client) reconnect() {
if !c.shouldReconn.Load() {
return
}
log.Printf("[ami] реконнект через %v...", c.backoff)
time.Sleep(c.backoff)
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
if err := c.Connect(); err != nil {
log.Printf("[ami] реконнект не удался: %v", err)
c.backoff *= 2
if c.backoff > 60*time.Second {
c.backoff = 60 * time.Second
}
return
}
c.backoff = 1 * time.Second
log.Printf("[ami] реконнект успешен")
}

View File

@ -27,7 +27,7 @@ type claims struct {
type contextKey string type contextKey string
const ( const (
userContextKey contextKey = "user" userContextKey contextKey = "user"
refreshTokenKey contextKey = "refresh_token" refreshTokenKey contextKey = "refresh_token"
) )
@ -39,8 +39,8 @@ type UserInfo struct {
} }
const ( const (
jwtExpiry = 15 * time.Minute jwtExpiry = 15 * time.Minute
refreshExpiry = 24 * time.Hour refreshExpiry = 24 * time.Hour
) )
// generateJWT создаёт access+refresh токены. // generateJWT создаёт access+refresh токены.
@ -139,7 +139,7 @@ func (a *API) apiKeyMiddleware(next http.Handler) http.Handler {
// handleLogin обрабатывает POST /api/auth/login. // handleLogin обрабатывает POST /api/auth/login.
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) { func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
var req models.LoginRequest var req models.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON") writeError(w, http.StatusBadRequest, "некорректный JSON")
return return
} }
@ -199,10 +199,6 @@ func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
// handleRefresh выдаёт новый access токен по refresh токену. // handleRefresh выдаёт новый access токен по refresh токену.
func (a *API) handleRefresh(w http.ResponseWriter, r *http.Request) { func (a *API) handleRefresh(w http.ResponseWriter, r *http.Request) {
tokenStr := extractBearerToken(r) tokenStr := extractBearerToken(r)
if tokenStr == "" {
// пробуем получить из query param (для WebSocket)
tokenStr = r.URL.Query().Get("token")
}
if tokenStr == "" { if tokenStr == "" {
writeError(w, http.StatusUnauthorized, "требуется токен") writeError(w, http.StatusUnauthorized, "требуется токен")
return return

View File

@ -17,12 +17,12 @@ func (a *API) handleHealth(w http.ResponseWriter, r *http.Request) {
if a.eslClient != nil { if a.eslClient != nil {
eslStats := a.eslClient.GetStats() eslStats := a.eslClient.GetStats()
eslInfo = map[string]interface{}{ eslInfo = map[string]interface{}{
"status": eslStats.Status, "status": eslStats.Status,
"host": eslStats.Host, "host": eslStats.Host,
"uptime_seconds": eslStats.UptimeSec, "uptime_seconds": eslStats.UptimeSec,
"reconnects": eslStats.Reconnects, "reconnects": eslStats.Reconnects,
"gateway_ops": eslStats.GatewayOps, "gateway_ops": eslStats.GatewayOps,
"events_recv": eslStats.EventsRecv, "events_recv": eslStats.EventsRecv,
} }
} }

View File

@ -38,6 +38,26 @@ func (a *API) handlePrometheus(w http.ResponseWriter, r *http.Request) {
var sb strings.Builder var sb strings.Builder
// Общие метрики (counter gauge)
stats := a.engine.GetHealthStats()
fmt.Fprintf(&sb, "# HELP pulse_route_requests_total Total number of route requests.\n")
fmt.Fprintf(&sb, "# TYPE pulse_route_requests_total counter\n")
fmt.Fprintf(&sb, "pulse_route_requests_total %d\n", stats.RouteRequests)
fmt.Fprintf(&sb, "# HELP pulse_nodes_total Total nodes registered.\n")
fmt.Fprintf(&sb, "# TYPE pulse_nodes_total gauge\n")
fmt.Fprintf(&sb, "pulse_nodes_total %d\n", stats.TotalNodes)
fmt.Fprintf(&sb, "# HELP pulse_nodes_healthy Healthy nodes count.\n")
fmt.Fprintf(&sb, "# TYPE pulse_nodes_healthy gauge\n")
fmt.Fprintf(&sb, "pulse_nodes_healthy %d\n", stats.HealthyNodes)
fmt.Fprintf(&sb, "# HELP pulse_uptime_seconds Uptime in seconds.\n")
fmt.Fprintf(&sb, "# TYPE pulse_uptime_seconds gauge\n")
fmt.Fprintf(&sb, "pulse_uptime_seconds %d\n", stats.UptimeSeconds)
fmt.Fprintf(&sb, "\n")
for _, n := range nodes { for _, n := range nodes {
id := sanitizePromLabel(n.NodeID) id := sanitizePromLabel(n.NodeID)

View File

@ -9,12 +9,12 @@ import (
// rateLimiter — in-memory token bucket rate limiter. // rateLimiter — in-memory token bucket rate limiter.
type rateLimiter struct { type rateLimiter struct {
mu sync.Mutex mu sync.Mutex
buckets map[string]*tokenBucket buckets map[string]*tokenBucket
rate float64 // токенов в секунду rate float64 // токенов в секунду
burst int // максимальный размер бакета burst int // максимальный размер бакета
cleanupInterval time.Duration cleanupInterval time.Duration
lastCleanup time.Time lastCleanup time.Time
} }
type tokenBucket struct { type tokenBucket struct {
@ -28,11 +28,11 @@ func newRateLimiter(ratePerSec, burst int) *rateLimiter {
burst = ratePerSec burst = ratePerSec
} }
return &rateLimiter{ return &rateLimiter{
buckets: make(map[string]*tokenBucket), buckets: make(map[string]*tokenBucket),
rate: float64(ratePerSec), rate: float64(ratePerSec),
burst: burst, burst: burst,
cleanupInterval: 60 * time.Second, cleanupInterval: 60 * time.Second,
lastCleanup: time.Now(), lastCleanup: time.Now(),
} }
} }

View File

@ -27,7 +27,7 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, models.RouteResponse{ writeJSON(w, http.StatusOK, models.RouteResponse{
Fallback: true, Fallback: true,
SIPGateway: fallbackGW, SIPGateway: fallbackGW,
Reason: "all_nodes_unhealthy", Reason: "all_nodes_unhealthy",
Nodes: nodes, Nodes: nodes,
}) })

View File

@ -18,8 +18,8 @@ type API struct {
jwtSecret string jwtSecret string
monitoringAPIKey string monitoringAPIKey string
wsHub *wsHub wsHub *wsHub
natsConnected func() bool // колбэк для проверки NATS-статуса (health) natsConnected func() bool // колбэк для проверки NATS-статуса (health)
eslClient *esl.Client // ESL-клиент (nil если не сконфигурирован) eslClient *esl.Client // ESL-клиент (nil если не сконфигурирован)
logFormat string logFormat string
routeLimiter *rateLimiter routeLimiter *rateLimiter
apiLimiter *rateLimiter apiLimiter *rateLimiter
@ -141,9 +141,13 @@ func (a *API) BroadcastGatewayEvent(gatewayName, trunkID, status string) {
a.wsHub.broadcast(msg) a.wsHub.broadcast(msg)
} }
// decodeJSON декодирует тело запроса в структуру v. // maxBodySize — максимальный размер тела запроса в байтах (1 MiB).
const maxBodySize = 1 << 20
// decodeJSON декодирует тело запроса в структуру v с ограничением размера.
func decodeJSON(r *http.Request, v interface{}) error { func decodeJSON(r *http.Request, v interface{}) error {
defer r.Body.Close() defer r.Body.Close()
r.Body = http.MaxBytesReader(nil, r.Body, maxBodySize)
if err := json.NewDecoder(r.Body).Decode(v); err != nil { if err := json.NewDecoder(r.Body).Decode(v); err != nil {
return fmt.Errorf("декодирование JSON: %w", err) return fmt.Errorf("декодирование JSON: %w", err)
} }

View File

@ -268,8 +268,9 @@ func (a *API) eslPushGatewayDelete(trunk models.Trunk) {
// gatewayParams возвращает параметры для создания FS gateway на основе транка. // gatewayParams возвращает параметры для создания FS gateway на основе транка.
func (a *API) gatewayParams(trunk models.Trunk) (profile, name, proxy string) { func (a *API) gatewayParams(trunk models.Trunk) (profile, name, proxy string) {
profile = a.readConfig().ESL.SofiaProfile cfg := a.readConfig()
name = fmt.Sprintf("pulse-ingress-%s", trunk.ID) profile = cfg.ESL.SofiaProfile
name = fmt.Sprintf("%s-ingress-%s", cfg.ESL.GatewayPrefix, trunk.ID)
proxy = extractHost(trunk.Gateway) proxy = extractHost(trunk.Gateway)
return return
} }

View File

@ -1,13 +1,12 @@
package api package api
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
"github.com/pulse-lets-go/internal/models" "github.com/pulse-lets-go/internal/models"
"github.com/pulse-lets-go/internal/util"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -227,15 +226,5 @@ func findUserIndex(users []models.User, id string) int {
// generateID генерирует ID вида "prefix-XXXXX". // generateID генерирует ID вида "prefix-XXXXX".
func generateID(prefix string) string { func generateID(prefix string) string {
return fmt.Sprintf("%s-%s", prefix, randomHex(6)) return fmt.Sprintf("%s-%s", prefix, util.RandomHex(6))
}
// randomHex генерирует случайную hex-строку заданной длины.
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// fallback на time-based, если crypto/rand недоступен
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)[:n]
} }

View File

@ -27,9 +27,9 @@ type ScoringWeights struct {
// ScoringConfig — пороги и веса для scoring engine. // ScoringConfig — пороги и веса для scoring engine.
type ScoringConfig struct { type ScoringConfig struct {
IdleCPUMin float64 `json:"idle_cpu_min"` IdleCPUMin float64 `json:"idle_cpu_min"`
CallFailureRateLethal float64 `json:"call_failure_rate_lethal"` CallFailureRateLethal float64 `json:"call_failure_rate_lethal"`
Weights ScoringWeights `json:"weights"` Weights ScoringWeights `json:"weights"`
} }
// TLSConfig — настройки HTTPS. // TLSConfig — настройки HTTPS.
@ -54,11 +54,11 @@ type LogConfig struct {
// ESLConfig — настройки подключения к FreeSWITCH ESL. // ESLConfig — настройки подключения к FreeSWITCH ESL.
type ESLConfig struct { type ESLConfig struct {
Host string `json:"host"` // если пустой — ESL не запускается Host string `json:"host"` // если пустой — ESL не запускается
Port int `json:"port"` // порт ESL (по умолчанию 8021) Port int `json:"port"` // порт ESL (по умолчанию 8021)
Password string `json:"password"` Password string `json:"password"`
PasswordEnv string `json:"password_env"` // имя переменной окружения с паролем PasswordEnv string `json:"password_env"` // имя переменной окружения с паролем
SofiaProfile string `json:"sofia_profile"` // профиль Sofia (external/internal) SofiaProfile string `json:"sofia_profile"` // профиль Sofia (external/internal)
GatewayPrefix string `json:"gateway_prefix"` // префикс gateway (pulse) GatewayPrefix string `json:"gateway_prefix"` // префикс gateway (pulse)
} }
@ -72,20 +72,31 @@ func (e *ESLConfig) GetPassword() string {
return e.Password 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). // Config — корневая конфигурация приложения (config.json).
type Config struct { type Config struct {
NatsURL string `json:"nats_url"` NatsURL string `json:"nats_url"`
NatsUser string `json:"nats_user"` NatsUser string `json:"nats_user"`
NatsPassword string `json:"nats_password"` NatsPassword string `json:"nats_password"`
ListenAddr string `json:"listen_addr"` ListenAddr string `json:"listen_addr"`
JWTSecret string `json:"jwt_secret"` JWTSecret string `json:"jwt_secret"`
MonitoringAPIKey string `json:"monitoring_api_key"` JWTSecretEnv string `json:"jwt_secret_env"` // имя переменной окружения с JWT-секретом
StaleThresholdSec int `json:"stale_threshold_sec"` MonitoringAPIKey string `json:"monitoring_api_key"`
Scoring ScoringConfig `json:"scoring"` StaleThresholdSec int `json:"stale_threshold_sec"`
TLS TLSConfig `json:"tls"` Scoring ScoringConfig `json:"scoring"`
RateLimit RateLimitConfig `json:"rate_limit"` TLS TLSConfig `json:"tls"`
Log LogConfig `json:"log"` RateLimit RateLimitConfig `json:"rate_limit"`
ESL ESLConfig `json:"esl"` Log LogConfig `json:"log"`
ESL ESLConfig `json:"esl"`
} }
// Validate проверяет корректность всех полей конфига. // Validate проверяет корректность всех полей конфига.
@ -168,8 +179,8 @@ func (c *Config) Validate() error {
// Manager управляет чтением и атомарной записью JSON-конфигов. // Manager управляет чтением и атомарной записью JSON-конфигов.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
dir string // путь к data/ dir string // путь к data/
} }
// NewManager создаёт новый менеджер конфигов из директории dataDir. // NewManager создаёт новый менеджер конфигов из директории dataDir.
@ -302,9 +313,9 @@ func (m *Manager) saveUsersLocked(users []models.User) error {
// --- Приватные методы --- // --- Приватные методы ---
func (m *Manager) configPath() string { return filepath.Join(m.dir, "config.json") } 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) trunksPath() string { return filepath.Join(m.dir, "trunks.json") }
func (m *Manager) usersPath() string { return filepath.Join(m.dir, "users.json") } func (m *Manager) usersPath() string { return filepath.Join(m.dir, "users.json") }
// saveFile атомарно сохраняет данные в файл: tmp → rename. // saveFile атомарно сохраняет данные в файл: tmp → rename.
// Вызывающий держит write lock. // Вызывающий держит write lock.
@ -355,7 +366,7 @@ func (m *Manager) createDefaultConfig() error {
MonitoringAPIKey: apiKey, MonitoringAPIKey: apiKey,
StaleThresholdSec: 20, StaleThresholdSec: 20,
Scoring: ScoringConfig{ Scoring: ScoringConfig{
IdleCPUMin: 5.0, IdleCPUMin: 5.0,
CallFailureRateLethal: 15.0, CallFailureRateLethal: 15.0,
Weights: ScoringWeights{ Weights: ScoringWeights{
CallScore: 0.40, CallScore: 0.40,

View File

@ -190,11 +190,11 @@ func (e *Engine) IncrementRouteRequests() {
// HealthStats возвращает статистику для health endpoint. // HealthStats возвращает статистику для health endpoint.
type HealthStats struct { type HealthStats struct {
TotalNodes int `json:"total_nodes"` TotalNodes int `json:"total_nodes"`
HealthyNodes int `json:"healthy_nodes"` HealthyNodes int `json:"healthy_nodes"`
RouteRequests int64 `json:"route_requests_total"` RouteRequests int64 `json:"route_requests_total"`
UptimeSeconds int64 `json:"uptime_seconds"` UptimeSeconds int64 `json:"uptime_seconds"`
LastMetricTS int64 `json:"last_metric_ts"` LastMetricTS int64 `json:"last_metric_ts"`
} }
// GetHealthStats возвращает текущую HealthStats (потокобезопасно). // GetHealthStats возвращает текущую HealthStats (потокобезопасно).
@ -209,11 +209,11 @@ func (e *Engine) GetHealthStats() HealthStats {
} }
} }
return HealthStats{ return HealthStats{
TotalNodes: len(e.nodes), TotalNodes: len(e.nodes),
HealthyNodes: healthy, HealthyNodes: healthy,
RouteRequests: e.routeRequests.Load(), RouteRequests: e.routeRequests.Load(),
UptimeSeconds: int64(time.Since(e.startTime).Seconds()), UptimeSeconds: int64(time.Since(e.startTime).Seconds()),
LastMetricTS: e.lastMetricTime.Load(), LastMetricTS: e.lastMetricTime.Load(),
} }
} }

View File

@ -12,7 +12,7 @@ func defaultCfg() *config.Config {
return &config.Config{ return &config.Config{
StaleThresholdSec: 20, StaleThresholdSec: 20,
Scoring: config.ScoringConfig{ Scoring: config.ScoringConfig{
IdleCPUMin: 5.0, IdleCPUMin: 5.0,
CallFailureRateLethal: 15.0, CallFailureRateLethal: 15.0,
Weights: config.ScoringWeights{ Weights: config.ScoringWeights{
CallScore: 0.40, CallScore: 0.40,

View File

@ -36,10 +36,10 @@ type Client struct {
port int port int
password string password string
conn net.Conn conn net.Conn
reader *bufio.Reader reader *bufio.Reader
mu sync.Mutex // защищает отправку синхронных команд (auth, subscribe) mu sync.Mutex // защищает отправку синхронных команд (auth, subscribe)
sendMu sync.Mutex // защищает запись в сокет (async gateway commands) sendMu sync.Mutex // защищает запись в сокет (async gateway commands)
connected atomic.Bool connected atomic.Bool
eventsCh chan *Event eventsCh chan *Event
@ -109,7 +109,7 @@ func (c *Client) tryConnect() error {
c.closeCh = make(chan struct{}) c.closeCh = make(chan struct{})
} }
addr := fmt.Sprintf("%s:%d", c.host, c.port) addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port))
dialer := net.Dialer{Timeout: 5 * time.Second} dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.Dial("tcp", addr) conn, err := dialer.Dial("tcp", addr)
if err != nil { if err != nil {
@ -119,7 +119,7 @@ func (c *Client) tryConnect() error {
c.reader = bufio.NewReader(conn) c.reader = bufio.NewReader(conn)
// 1. Читаем auth/request // 1. Читаем auth/request
headers, _, err := c.readMessageLocked() headers, _, err := c.readMessage()
if err != nil { if err != nil {
conn.Close() conn.Close()
return fmt.Errorf("чтение auth/request: %w", err) return fmt.Errorf("чтение auth/request: %w", err)
@ -137,7 +137,7 @@ func (c *Client) tryConnect() error {
} }
// 3. Читаем auth response // 3. Читаем auth response
headers, _, err = c.readMessageLocked() headers, _, err = c.readMessage()
if err != nil { if err != nil {
conn.Close() conn.Close()
return fmt.Errorf("чтение auth response: %w", err) return fmt.Errorf("чтение auth response: %w", err)
@ -199,7 +199,7 @@ func (c *Client) Send(command string) (map[string]string, string, error) {
} }
// Читаем ответ // Читаем ответ
return c.readMessageLocked() return c.readMessage()
} }
// Subscribe подписывается на события ESL. // Subscribe подписывается на события ESL.
@ -263,7 +263,7 @@ func (c *Client) readEventsLoop() {
continue continue
} }
headers, body, err := c.readMessageUnlocked() headers, body, err := c.readMessage()
if err != nil { if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
@ -330,48 +330,9 @@ func (c *Client) reconnect() {
// --- Приватные методы --- // --- Приватные методы ---
// readMessageUnlocked читает одно ESL-сообщение: заголовки + тело. // readMessage читает одно ESL-сообщение: заголовки + тело.
// Должна вызываться только из readEventsLoop (одиночный читатель). // Вызывающий должен гарантировать монопольный доступ к c.reader.
func (c *Client) readMessageUnlocked() (map[string]string, string, error) { func (c *Client) readMessage() (map[string]string, string, error) {
headers := make(map[string]string)
for {
line, err := c.reader.ReadString('\n')
if err != nil {
return nil, "", err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break // конец заголовков
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
// Читаем тело согласно Content-Length
cl := headers["Content-Length"]
if cl == "" {
return headers, "", nil
}
length, err := strconv.Atoi(cl)
if err != nil {
return headers, "", fmt.Errorf("некорректный Content-Length: %s", cl)
}
body := make([]byte, length)
if _, err := io.ReadFull(c.reader, body); err != nil {
return headers, "", fmt.Errorf("чтение тела (Content-Length=%d): %w", length, err)
}
return headers, strings.TrimSpace(string(body)), nil
}
// readMessageLocked читает одно ESL-сообщение: заголовки + тело.
// Вызывающий держит c.mu (для синхронных команд Send).
func (c *Client) readMessageLocked() (map[string]string, string, error) {
headers := make(map[string]string) headers := make(map[string]string)
for { for {
@ -412,12 +373,12 @@ func (c *Client) readMessageLocked() (map[string]string, string, error) {
// Stats возвращает статистику ESL-клиента для health endpoint. // Stats возвращает статистику ESL-клиента для health endpoint.
type Stats struct { type Stats struct {
Status string `json:"status"` Status string `json:"status"`
Host string `json:"host"` Host string `json:"host"`
Reconnects int64 `json:"reconnects"` Reconnects int64 `json:"reconnects"`
GatewayOps int64 `json:"gateway_ops_total"` GatewayOps int64 `json:"gateway_ops_total"`
EventsRecv int64 `json:"events_received"` EventsRecv int64 `json:"events_received"`
UptimeSec int64 `json:"uptime_seconds"` UptimeSec int64 `json:"uptime_seconds"`
} }
// GetStats возвращает текущую статистику. // GetStats возвращает текущую статистику.

View File

@ -16,6 +16,7 @@ type EventHandler func(eventName string, headers map[string]string, body string)
// - Вызывает onConnect (например, для GatewaySyncAll) // - Вызывает onConnect (например, для GatewaySyncAll)
// - Подписывается на события SOFIA::gateway_register/unregister // - Подписывается на события SOFIA::gateway_register/unregister
// - Запускает чтение асинхронных событий // - Запускает чтение асинхронных событий
//
// При разрыве соединения — автоматический реконнект. // При разрыве соединения — автоматический реконнект.
func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) { func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) {
const subscribeEvents = "SOFIA::gateway_register SOFIA::gateway_unregister SOFIA::gateway_expire" const subscribeEvents = "SOFIA::gateway_register SOFIA::gateway_unregister SOFIA::gateway_expire"

View File

@ -32,8 +32,8 @@ func (c *Client) GatewayDelete(profile, name string) error {
return nil return nil
} }
// GatewayList возвращает список имён gateway в заданном профиле. // GatewayList возвращает список имён gateway в заданном профиле с указанным префиксом.
func (c *Client) GatewayList(profile string) ([]string, error) { func (c *Client) GatewayList(profile, prefix string) ([]string, error) {
cmd := fmt.Sprintf("api sofia profile %s gwlist", profile) cmd := fmt.Sprintf("api sofia profile %s gwlist", profile)
_, body, err := c.Send(cmd) _, body, err := c.Send(cmd)
@ -49,8 +49,8 @@ func (c *Client) GatewayList(profile string) ([]string, error) {
if line == "" || strings.HasPrefix(line, "Name:") || strings.HasPrefix(line, "====") { if line == "" || strings.HasPrefix(line, "Name:") || strings.HasPrefix(line, "====") {
continue continue
} }
// Ищем строки вида "pulse-ingress-xxxxx sip:..." // Ищем строки вида "<prefix>-ingress-xxxxx sip:..."
if strings.Contains(line, "pulse-") { if strings.Contains(line, prefix+"-ingress-") {
parts := strings.Fields(line) parts := strings.Fields(line)
if len(parts) > 0 { if len(parts) > 0 {
gateways = append(gateways, parts[0]) gateways = append(gateways, parts[0])

View File

@ -15,10 +15,10 @@ import (
// Logger — потокобезопасный логгер метрик в ASCII-файл с дневной ротацией. // Logger — потокобезопасный логгер метрик в ASCII-файл с дневной ротацией.
type Logger struct { type Logger struct {
mu sync.Mutex mu sync.Mutex
dir string dir string
file *os.File file *os.File
today string // YYYY-MM-DD последнего открытого файла today string // YYYY-MM-DD последнего открытого файла
} }
// NewLogger создаёт логгер, записывающий в dataDir/. // NewLogger создаёт логгер, записывающий в dataDir/.

View File

@ -7,15 +7,15 @@ import "time"
// NodeMetric — сообщение, публикуемое нодой в NATS каждые 5 сек. // NodeMetric — сообщение, публикуемое нодой в NATS каждые 5 сек.
type NodeMetric struct { type NodeMetric struct {
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
TS int64 `json:"ts"` // unix timestamp TS int64 `json:"ts"` // unix timestamp
Status string `json:"status"` // "ok", "degraded", "down" Status string `json:"status"` // "ok", "degraded", "down"
ActiveCalls int `json:"active_calls"` ActiveCalls int `json:"active_calls"`
MaxCalls int `json:"max_calls"` MaxCalls int `json:"max_calls"`
IdleCPU float64 `json:"idle_cpu"` // 0..100 % IdleCPU float64 `json:"idle_cpu"` // 0..100 %
LoadAvg float64 `json:"load_avg"` // system load average (1m) LoadAvg float64 `json:"load_avg"` // system load average (1m)
CallFailureRate float64 `json:"call_failure_rate"` // 0.0..100.0 % CallFailureRate float64 `json:"call_failure_rate"` // 0.0..100.0 %
SIPGateway string `json:"sip_gateway,omitempty"` // SIP-адрес ноды для авто-создания транка SIPGateway string `json:"sip_gateway,omitempty"` // SIP-адрес ноды для авто-создания транка
} }
// --- In-memory состояние ноды в engine --- // --- In-memory состояние ноды в engine ---
@ -24,8 +24,8 @@ type NodeMetric struct {
// Содержит данные последней метрики + поля ручного управления. // Содержит данные последней метрики + поля ручного управления.
type NodeState struct { type NodeState struct {
NodeMetric NodeMetric
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
DisabledReason string `json:"disabled_reason"` DisabledReason string `json:"disabled_reason"`
Score float64 `json:"score"` Score float64 `json:"score"`
LethalReason string `json:"lethal_reason,omitempty"` // причина, если score = -100 LethalReason string `json:"lethal_reason,omitempty"` // причина, если score = -100
} }
@ -46,7 +46,7 @@ type Trunk struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type TrunkType `json:"type"` Type TrunkType `json:"type"`
NodeID string `json:"node_id,omitempty"` // только для balance NodeID string `json:"node_id,omitempty"` // только для balance
Gateway string `json:"gateway"` Gateway string `json:"gateway"`
Codecs []string `json:"codecs,omitempty"` Codecs []string `json:"codecs,omitempty"`
Context string `json:"context,omitempty"` Context string `json:"context,omitempty"`
@ -68,10 +68,10 @@ const (
// User — модель пользователя (из users.json). // User — модель пользователя (из users.json).
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
PasswordHash string `json:"password_hash"` PasswordHash string `json:"password_hash"`
Role UserRole `json:"role"` Role UserRole `json:"role"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@ -96,27 +96,27 @@ type LoginResponse struct {
// RouteResponse — ответ на запрос маршрутизации. // RouteResponse — ответ на запрос маршрутизации.
type RouteResponse struct { type RouteResponse struct {
// Успех // Успех
NodeID string `json:"node_id,omitempty"` NodeID string `json:"node_id,omitempty"`
Score float64 `json:"score,omitempty"` Score float64 `json:"score,omitempty"`
SIPGateway string `json:"sip_gateway,omitempty"` SIPGateway string `json:"sip_gateway,omitempty"`
// Fallback // Fallback
Fallback bool `json:"fallback,omitempty"` Fallback bool `json:"fallback,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
// Общая информация о нодах // Общая информация о нодах
Nodes []RouteNodeInfo `json:"nodes,omitempty"` Nodes []RouteNodeInfo `json:"nodes,omitempty"`
// Ошибка // Ошибка
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// RouteNodeInfo — информация о ноде в ответе маршрутизации. // RouteNodeInfo — информация о ноде в ответе маршрутизации.
type RouteNodeInfo struct { type RouteNodeInfo struct {
ID string `json:"id"` ID string `json:"id"`
Score float64 `json:"score"` Score float64 `json:"score"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Status string `json:"status"` Status string `json:"status"`
} }
// --- API: Nodes --- // --- API: Nodes ---
@ -129,20 +129,20 @@ type ToggleNodeRequest struct {
// NodeInfo — информация о ноде для фронта. // NodeInfo — информация о ноде для фронта.
type NodeInfo struct { type NodeInfo struct {
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
TS int64 `json:"ts"` TS int64 `json:"ts"`
Status string `json:"status"` Status string `json:"status"`
ActiveCalls int `json:"active_calls"` ActiveCalls int `json:"active_calls"`
MaxCalls int `json:"max_calls"` MaxCalls int `json:"max_calls"`
IdleCPU float64 `json:"idle_cpu"` IdleCPU float64 `json:"idle_cpu"`
LoadAvg float64 `json:"load_avg"` LoadAvg float64 `json:"load_avg"`
CallFailureRate float64 `json:"call_failure_rate"` CallFailureRate float64 `json:"call_failure_rate"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
DisabledReason string `json:"disabled_reason,omitempty"` DisabledReason string `json:"disabled_reason,omitempty"`
Score float64 `json:"score"` Score float64 `json:"score"`
LethalReason string `json:"lethal_reason,omitempty"` LethalReason string `json:"lethal_reason,omitempty"`
IsStale bool `json:"is_stale"` IsStale bool `json:"is_stale"`
SecondsAgo float64 `json:"seconds_ago"` SecondsAgo float64 `json:"seconds_ago"`
} }
// --- API: Users --- // --- API: Users ---
@ -191,17 +191,17 @@ type UpdateTrunkRequest struct {
// ZabbixNode — элемент LLD (Low-Level Discovery) для Zabbix. // ZabbixNode — элемент LLD (Low-Level Discovery) для Zabbix.
type ZabbixNode struct { type ZabbixNode struct {
NodeID string `json:"{#NODE_ID}"` NodeID string `json:"{#NODE_ID}"`
Status string `json:"{#STATUS}"` Status string `json:"{#STATUS}"`
ActiveCalls int `json:"{#ACTIVE_CALLS}"` ActiveCalls int `json:"{#ACTIVE_CALLS}"`
MaxCalls int `json:"{#MAX_CALLS}"` MaxCalls int `json:"{#MAX_CALLS}"`
IdleCPU float64 `json:"{#IDLE_CPU}"` IdleCPU float64 `json:"{#IDLE_CPU}"`
LoadAvg float64 `json:"{#LOAD_AVG}"` LoadAvg float64 `json:"{#LOAD_AVG}"`
CallFailureRate float64 `json:"{#CALL_FAILURE_RATE}"` CallFailureRate float64 `json:"{#CALL_FAILURE_RATE}"`
Score float64 `json:"{#SCORE}"` Score float64 `json:"{#SCORE}"`
Disabled bool `json:"{#DISABLED}"` Disabled bool `json:"{#DISABLED}"`
IsStale bool `json:"{#IS_STALE}"` IsStale bool `json:"{#IS_STALE}"`
SecondsAgo float64 `json:"{#SECONDS_AGO}"` SecondsAgo float64 `json:"{#SECONDS_AGO}"`
} }
// ZabbixResponse — ответ на запрос Zabbix LLD. // ZabbixResponse — ответ на запрос Zabbix LLD.
@ -213,6 +213,6 @@ type ZabbixResponse struct {
// WsMetricsMessage — сообщение, отправляемое по WebSocket. // WsMetricsMessage — сообщение, отправляемое по WebSocket.
type WsMetricsMessage struct { type WsMetricsMessage struct {
Type string `json:"type"` // "nodes_update", "node_toggle", etc Type string `json:"type"` // "nodes_update", "node_toggle", etc
Payload interface{} `json:"payload"` Payload interface{} `json:"payload"`
} }

View File

@ -9,9 +9,9 @@ import (
natsgo "github.com/nats-io/nats.go" natsgo "github.com/nats-io/nats.go"
"github.com/pulse-lets-go/internal/models"
"github.com/pulse-lets-go/internal/engine" "github.com/pulse-lets-go/internal/engine"
filelog "github.com/pulse-lets-go/internal/log" filelog "github.com/pulse-lets-go/internal/log"
"github.com/pulse-lets-go/internal/models"
) )
const subject = "pulse.metrics.>" const subject = "pulse.metrics.>"

18
internal/util/util.go Normal file
View File

@ -0,0 +1,18 @@
package util
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
)
// RandomHex генерирует случайную hex-строку из n байт.
// При ошибке crypto/rand использует time-based fallback.
func RandomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())[:n*2]
}
return hex.EncodeToString(b)[:n*2]
}