Maksim Totmin cc9da3ad7d 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
2026-06-25 19:30:36 +07:00

308 lines
8.6 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 api
import (
"fmt"
"log"
"net/http"
"sort"
"strings"
"time"
"github.com/pulse-lets-go/internal/config"
"github.com/pulse-lets-go/internal/models"
)
// handleGetTrunks — GET /api/trunks — список всех транков с опциональным фильтром.
func (a *API) handleGetTrunks(w http.ResponseWriter, r *http.Request) {
trunks, err := a.configManager.ReadTrunks()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения транков")
return
}
// Фильтр по типу
typeFilter := r.URL.Query().Get("type")
if typeFilter != "" {
filtered := make([]models.Trunk, 0)
for _, t := range trunks {
if string(t.Type) == typeFilter {
filtered = append(filtered, t)
}
}
trunks = filtered
}
// Сортировка по created_at
sort.Slice(trunks, func(i, j int) bool {
return trunks[i].CreatedAt.Before(trunks[j].CreatedAt)
})
writeJSON(w, http.StatusOK, trunks)
}
// handleCreateTrunk — POST /api/trunks — создание нового транка.
func (a *API) handleCreateTrunk(w http.ResponseWriter, r *http.Request) {
var req models.CreateTrunkRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
if err := validateTrunk(req.Name, req.Type, req.Gateway); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Проверяем: fallback может быть только один
trunks, err := a.configManager.ReadTrunks()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения транков")
return
}
if req.Type == models.TrunkFallback {
for _, t := range trunks {
if t.Type == models.TrunkFallback {
writeError(w, http.StatusConflict, "fallback-транк уже существует (должен быть ровно 1)")
return
}
}
}
now := time.Now().UTC()
trunk := models.Trunk{
ID: generateID("trk"),
Name: req.Name,
Type: req.Type,
NodeID: req.NodeID,
Gateway: req.Gateway,
Codecs: req.Codecs,
Context: req.Context,
Enabled: req.Enabled,
Description: req.Description,
CreatedAt: now,
UpdatedAt: now,
}
trunks = append(trunks, trunk)
if err := a.configManager.SaveTrunks(trunks); err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сохранения транка")
return
}
// ESL push: создаём gateway на FS для ingress-транка
a.eslPushGatewayAdd(trunk)
writeJSON(w, http.StatusCreated, trunk)
}
// handleUpdateTrunk — PUT /api/trunks/{id} — обновление транка.
func (a *API) handleUpdateTrunk(w http.ResponseWriter, r *http.Request) {
trunkID := r.PathValue("id")
if trunkID == "" {
writeError(w, http.StatusBadRequest, "не указан id транка")
return
}
var req models.UpdateTrunkRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
trunks, err := a.configManager.ReadTrunks()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения транков")
return
}
idx := findTrunkIndex(trunks, trunkID)
if idx < 0 {
writeError(w, http.StatusNotFound, "транк не найден")
return
}
// Применяем частичные обновления
t := &trunks[idx]
if req.Name != nil {
t.Name = *req.Name
}
if req.Type != nil {
// Проверка fallback-уникальности
if *req.Type == models.TrunkFallback && t.Type != models.TrunkFallback {
for _, other := range trunks {
if other.Type == models.TrunkFallback && other.ID != trunkID {
writeError(w, http.StatusConflict, "fallback-транк уже существует")
return
}
}
}
t.Type = *req.Type
}
if req.NodeID != nil {
t.NodeID = *req.NodeID
}
if req.Gateway != nil {
t.Gateway = *req.Gateway
}
if req.Codecs != nil {
t.Codecs = req.Codecs
}
if req.Context != nil {
t.Context = *req.Context
}
if req.Enabled != nil {
t.Enabled = *req.Enabled
}
if req.Description != nil {
t.Description = *req.Description
}
t.UpdatedAt = time.Now().UTC()
if err := a.configManager.SaveTrunks(trunks); err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сохранения транка")
return
}
// ESL push: обновляем gateway на FS
a.eslPushGatewayUpdate(*t)
writeJSON(w, http.StatusOK, t)
}
// handleDeleteTrunk — DELETE /api/trunks/{id} — удаление транка.
func (a *API) handleDeleteTrunk(w http.ResponseWriter, r *http.Request) {
trunkID := r.PathValue("id")
if trunkID == "" {
writeError(w, http.StatusBadRequest, "не указан id транка")
return
}
trunks, err := a.configManager.ReadTrunks()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения транков")
return
}
idx := findTrunkIndex(trunks, trunkID)
if idx < 0 {
writeError(w, http.StatusNotFound, "транк не найден")
return
}
deletedTrunk := trunks[idx]
trunks = append(trunks[:idx], trunks[idx+1:]...)
// ESL push: удаляем gateway на FS
a.eslPushGatewayDelete(deletedTrunk)
if err := a.configManager.SaveTrunks(trunks); err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сохранения транков")
return
}
writeJSON(w, http.StatusNoContent, nil)
}
// --- helpers ---
func validateTrunk(name string, trunkType models.TrunkType, gateway string) error {
if name == "" {
return fmt.Errorf("имя транка обязательно")
}
if gateway == "" {
return fmt.Errorf("gateway обязателен")
}
switch trunkType {
case models.TrunkIngress, models.TrunkBalance, models.TrunkFallback:
break
default:
return fmt.Errorf("некорректный тип транка: %s", trunkType)
}
return nil
}
func findTrunkIndex(trunks []models.Trunk, id string) int {
for i := range trunks {
if trunks[i].ID == id {
return i
}
}
return -1
}
// --- ESL push helpers ---
// eslPushGatewayAdd создаёт gateway на FS для ingress-транка.
func (a *API) eslPushGatewayAdd(trunk models.Trunk) {
if a.eslClient == nil || !a.eslClient.IsConnected() || trunk.Type != models.TrunkIngress {
return
}
profile, name, proxy := a.gatewayParams(trunk)
if err := a.eslClient.GatewayAdd(profile, name, proxy); err != nil {
log.Printf("[esl] GatewayAdd %s: %v", name, err)
}
}
// eslPushGatewayUpdate обновляет gateway на FS (удаляет старый + создаёт новый).
func (a *API) eslPushGatewayUpdate(trunk models.Trunk) {
if a.eslClient == nil || !a.eslClient.IsConnected() || trunk.Type != models.TrunkIngress {
return
}
profile, name, proxy := a.gatewayParams(trunk)
_ = a.eslClient.GatewayDelete(profile, name) // игнорируем ошибку — может не существовать
if err := a.eslClient.GatewayAdd(profile, name, proxy); err != nil {
log.Printf("[esl] GatewayUpdate %s: %v", name, err)
}
}
// eslPushGatewayDelete удаляет gateway на FS для ingress-транка.
func (a *API) eslPushGatewayDelete(trunk models.Trunk) {
if a.eslClient == nil || !a.eslClient.IsConnected() || trunk.Type != models.TrunkIngress {
return
}
profile, name, _ := a.gatewayParams(trunk)
if err := a.eslClient.GatewayDelete(profile, name); err != nil {
log.Printf("[esl] GatewayDelete %s: %v", name, err)
}
}
// gatewayParams возвращает параметры для создания FS gateway на основе транка.
func (a *API) gatewayParams(trunk models.Trunk) (profile, name, proxy string) {
cfg := a.readConfig()
profile = cfg.ESL.SofiaProfile
name = fmt.Sprintf("%s-ingress-%s", cfg.ESL.GatewayPrefix, trunk.ID)
proxy = extractHost(trunk.Gateway)
return
}
// extractHost извлекает хост из SIP URI.
// "sip:mts-gw.lan:5060" → "mts-gw.lan"
func extractHost(gateway string) string {
s := strings.TrimPrefix(gateway, "sip:")
s = strings.TrimPrefix(s, "sips:")
if idx := strings.LastIndex(s, ":"); idx > 0 {
// Проверяем, порт ли это (все цифры после двоеточия)
port := s[idx+1:]
isNumeric := true
for _, c := range port {
if c < '0' || c > '9' {
isNumeric = false
break
}
}
if isNumeric {
s = s[:idx]
}
}
return s
}
// readConfig возвращает текущий конфиг (для получения esl.sofia_profile).
func (a *API) readConfig() *config.Config {
cfg, _ := a.configManager.ReadConfig()
if cfg == nil {
return &config.Config{ESL: config.ESLConfig{SofiaProfile: "external"}}
}
return cfg
}