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

@ -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

@ -1,16 +1,16 @@
// SIP тестер для e2e проверки pulse-lets-go + FreeSWITCH. // SIP тестер для e2e проверки pulse-lets-go + FreeSWITCH.
// Два режима: // Два режима:
//
// uas — отвечает 200 OK на SIP INVITE, симулирует PBX-ноду // uas — отвечает 200 OK на SIP INVITE, симулирует PBX-ноду
// uac — шлёт SIP INVITE в FreeSWITCH, симулирует оператора // uac — шлёт SIP INVITE в FreeSWITCH, симулирует оператора
// //
// Примеры: // Примеры:
//
// ./siptest -mode uas (PBX-нода на порту 5090) // ./siptest -mode uas (PBX-нода на порту 5090)
// ./siptest -mode uac -r 10 -l 100 (оператор, 10 CPS, до 100 одновременных) // ./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 (
@ -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

@ -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

@ -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

@ -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

@ -72,6 +72,16 @@ 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"`
@ -79,6 +89,7 @@ type Config struct {
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"`
JWTSecretEnv string `json:"jwt_secret_env"` // имя переменной окружения с JWT-секретом
MonitoringAPIKey string `json:"monitoring_api_key"` MonitoringAPIKey string `json:"monitoring_api_key"`
StaleThresholdSec int `json:"stale_threshold_sec"` StaleThresholdSec int `json:"stale_threshold_sec"`
Scoring ScoringConfig `json:"scoring"` Scoring ScoringConfig `json:"scoring"`

View File

@ -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 {

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

@ -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]
}