329 lines
11 KiB
Go
329 lines
11 KiB
Go
// pulse-lets-go — балансировщик телефонной нагрузки на базе FreeSWITCH + NATS.
|
||
// Принимает метрики от нижестоящих АТС, вычисляет score готовности,
|
||
// возвращает лучшую ноду для следующего вызова через /api/route.
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/pulse-lets-go/internal/api"
|
||
"github.com/pulse-lets-go/internal/config"
|
||
"github.com/pulse-lets-go/internal/engine"
|
||
"github.com/pulse-lets-go/internal/esl"
|
||
filelog "github.com/pulse-lets-go/internal/log"
|
||
"github.com/pulse-lets-go/internal/models"
|
||
"github.com/pulse-lets-go/internal/nats"
|
||
"github.com/pulse-lets-go/internal/util"
|
||
)
|
||
|
||
func main() {
|
||
// Определяем рабочую директорию
|
||
// По умолчанию data/ — родительская папка от бинарника.
|
||
// Пример: /opt/pulse-lets-go/bin/pulse-lets-go → /opt/pulse-lets-go/data
|
||
exe, _ := os.Executable()
|
||
exeDir := filepath.Dir(exe)
|
||
dataDir := filepath.Join(filepath.Dir(exeDir), "data")
|
||
|
||
if envDir := os.Getenv("PULSE_DATA_DIR"); envDir != "" {
|
||
dataDir = envDir
|
||
}
|
||
|
||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||
log.Printf("[main] запуск pulse-lets-go, data_dir=%s", dataDir)
|
||
|
||
// 1. Инициализация конфиг-менеджера (создаёт data/ и дефолтные JSON при первом запуске)
|
||
cfgMgr, err := config.NewManager(dataDir)
|
||
if err != nil {
|
||
log.Fatalf("[main] ошибка инициализации конфигов: %v", err)
|
||
}
|
||
|
||
// 2. Чтение конфига
|
||
cfg, err := cfgMgr.ReadConfig()
|
||
if err != nil {
|
||
log.Fatalf("[main] ошибка чтения config.json: %v", err)
|
||
}
|
||
|
||
// 2.1 Валидация конфига
|
||
if err := cfg.Validate(); err != nil {
|
||
log.Fatalf("[main] ошибка валидации конфига:\n%v", err)
|
||
}
|
||
|
||
log.Printf("[main] NATS=%s, listen=%s, stale_threshold=%dсек, tls=%v, log=%s/%s",
|
||
cfg.NatsURL, cfg.ListenAddr, cfg.StaleThresholdSec, cfg.TLS.Enabled, cfg.Log.Level, cfg.Log.Format)
|
||
|
||
// 3. Engine
|
||
eng := engine.NewEngine(cfg)
|
||
|
||
// 4. ASCII логгер метрик
|
||
metricsLogger, err := filelog.NewLogger(dataDir)
|
||
if err != nil {
|
||
log.Fatalf("[main] ошибка создания логгера: %v", err)
|
||
}
|
||
|
||
// 5. NATS подписчик
|
||
sub, err := nats.NewSubscriber(cfg.NatsURL, cfg.NatsUser, cfg.NatsPassword, eng, metricsLogger)
|
||
if err != nil {
|
||
log.Fatalf("[main] ошибка NATS: %v", err)
|
||
}
|
||
defer sub.Close()
|
||
|
||
// Авто-создание balance-транков при первой метрике от новой ноды
|
||
sub.SetOnNewNode(func(nodeID, sipGateway string) {
|
||
trunks, err := cfgMgr.ReadTrunks()
|
||
if err != nil {
|
||
log.Printf("[main] ошибка чтения транков для авто-создания: %v", err)
|
||
return
|
||
}
|
||
// Проверяем идемпотентность: транк уже существует?
|
||
for _, t := range trunks {
|
||
if t.Type == "balance" && t.NodeID == nodeID {
|
||
return
|
||
}
|
||
}
|
||
// Создаём новый balance-транк
|
||
now := time.Now().UTC()
|
||
trunkID := "trk-" + util.RandomHex(6)
|
||
trunk := models.Trunk{
|
||
ID: trunkID,
|
||
Name: fmt.Sprintf("%s баланс", nodeID),
|
||
Type: "balance",
|
||
NodeID: nodeID,
|
||
Gateway: sipGateway,
|
||
Codecs: []string{"PCMU", "PCMA"},
|
||
Context: "default",
|
||
Enabled: true,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
trunks = append(trunks, trunk)
|
||
if err := cfgMgr.SaveTrunks(trunks); err != nil {
|
||
log.Printf("[main] ошибка сохранения авто-созданного транка: %v", err)
|
||
return
|
||
}
|
||
log.Printf("[nats] авто-создан balance-транк: %s → %s (узел: %s)", trunkID, sipGateway, nodeID)
|
||
})
|
||
|
||
// 6. HTTP API
|
||
apiHandler := api.NewAPI(eng, cfgMgr, cfg.GetJWTSecret(), cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
||
handler := apiHandler.Handler()
|
||
|
||
// 6.1 ESL-клиент (опционально — если сконфигурирован)
|
||
var eslClient *esl.Client
|
||
if cfg.ESL.Host != "" {
|
||
eslPassword := cfg.ESL.GetPassword()
|
||
eslClient = esl.NewClient(cfg.ESL.Host, cfg.ESL.Port, eslPassword)
|
||
apiHandler.SetESLClient(eslClient)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
go func() {
|
||
eslClient.StartEventLoop(ctx, cfg.ESL.SofiaProfile,
|
||
func() {
|
||
trunks, err := cfgMgr.ReadTrunks()
|
||
if err != nil {
|
||
log.Printf("[esl] ошибка чтения транков для синхронизации: %v", err)
|
||
return
|
||
}
|
||
var specs []esl.GatewaySpec
|
||
for _, t := range trunks {
|
||
if t.Type == "ingress" && t.Enabled {
|
||
specs = append(specs, esl.GatewaySpec{
|
||
Name: fmt.Sprintf("pulse-ingress-%s", t.ID),
|
||
Proxy: extractHostFromGateway(t.Gateway),
|
||
})
|
||
}
|
||
}
|
||
eslClient.GatewaySyncAll(cfg.ESL.SofiaProfile, specs)
|
||
},
|
||
func(eventName string, headers map[string]string, body string) {
|
||
gwName := headers["Gateway-Name"]
|
||
if gwName == "" {
|
||
return
|
||
}
|
||
// Извлекаем trunk_id из имени gateway: "pulse-ingress-trk-X" → "trk-X"
|
||
prefix := cfg.ESL.GatewayPrefix + "-ingress-"
|
||
trunkID := strings.TrimPrefix(gwName, prefix)
|
||
if trunkID == gwName {
|
||
return // префикс не найден, не наш gateway
|
||
}
|
||
|
||
status := "unknown"
|
||
switch eventName {
|
||
case "SOFIA::gateway_register":
|
||
status = "up"
|
||
case "SOFIA::gateway_unregister", "SOFIA::gateway_expire":
|
||
status = "down"
|
||
}
|
||
|
||
apiHandler.BroadcastGatewayEvent(gwName, trunkID, status)
|
||
},
|
||
)
|
||
cancel()
|
||
}()
|
||
}
|
||
|
||
// ESL shutdown при завершении main
|
||
defer func() {
|
||
if eslClient != nil {
|
||
eslClient.Disconnect()
|
||
}
|
||
}()
|
||
|
||
// 6.2 Periodic WS broadcast + FS stats poll
|
||
go func() {
|
||
ticker := time.NewTicker(5 * time.Second)
|
||
for range ticker.C {
|
||
if eslClient != nil && eslClient.IsConnected() {
|
||
if stats, err := eslClient.FetchFsStats(); err == nil {
|
||
apiHandler.UpdateFsStats(stats)
|
||
}
|
||
}
|
||
apiHandler.BroadcastAll()
|
||
}
|
||
}()
|
||
|
||
// Подключаем раздачу SvelteKit статики, если директория существует
|
||
webDir := findWebDir(exeDir)
|
||
if webDir != "" {
|
||
log.Printf("[main] раздача SvelteKit статики из %s", webDir)
|
||
handler = withSPA(webDir, handler)
|
||
}
|
||
|
||
srv := &http.Server{
|
||
Addr: cfg.ListenAddr,
|
||
Handler: handler,
|
||
ReadTimeout: 10 * time.Second,
|
||
WriteTimeout: 10 * time.Second,
|
||
IdleTimeout: 60 * time.Second,
|
||
}
|
||
|
||
// 7. Запуск HTTP-сервера
|
||
go func() {
|
||
log.Printf("[main] HTTP сервер запущен на %s (tls=%v)", cfg.ListenAddr, cfg.TLS.Enabled)
|
||
log.Printf("[main] готов к работе:")
|
||
log.Printf(" GET /api/health — статус зависимостей")
|
||
log.Printf(" POST /api/auth/login — аутентификация")
|
||
log.Printf(" GET /api/route — маршрутизация (FreeSWITCH)")
|
||
log.Printf(" GET /api/nodes — список нод")
|
||
log.Printf(" GET /api/trunks — управление транками")
|
||
log.Printf(" GET /api/users — управление пользователями")
|
||
log.Printf(" GET /api/monitoring/zabbix — Zabbix LLD + items")
|
||
log.Printf(" GET /api/monitoring/prometheus — Prometheus метрики")
|
||
log.Printf(" WS /ws/metrics — real-time метрики на фронт")
|
||
|
||
var err error
|
||
if cfg.TLS.Enabled {
|
||
err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
||
} else {
|
||
err = srv.ListenAndServe()
|
||
}
|
||
if err != nil && err != http.ErrServerClosed {
|
||
log.Fatalf("[main] ошибка HTTP сервера: %v", err)
|
||
}
|
||
}()
|
||
|
||
// 8. Graceful shutdown
|
||
quit := make(chan os.Signal, 1)
|
||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||
sig := <-quit
|
||
log.Printf("[main] получен сигнал %s, завершение работы...", sig)
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
if err := srv.Shutdown(ctx); err != nil {
|
||
log.Printf("[main] ошибка остановки HTTP сервера: %v", err)
|
||
}
|
||
|
||
metricsLogger.Close()
|
||
|
||
log.Println("[main] pulse-lets-go остановлен")
|
||
}
|
||
|
||
// findWebDir ищет директорию со SvelteKit сборкой (web/build/).
|
||
// Пробует по порядку: PULSE_WEB_DIR, CWD, ../web/build, рядом с бинарником.
|
||
func findWebDir(exeDir string) string {
|
||
candidates := []string{
|
||
os.Getenv("PULSE_WEB_DIR"),
|
||
"web/build", // ./web/build (CWD — dev, systemd working dir)
|
||
filepath.Join(filepath.Dir(exeDir), "web", "build"), // ../web/build (корень проекта, dev с bin/)
|
||
filepath.Join(exeDir, "web", "build"), // рядом с бинарником (production install, make deploy)
|
||
}
|
||
for _, d := range candidates {
|
||
if d == "" {
|
||
continue
|
||
}
|
||
if stat, err := os.Stat(d); err == nil && stat.IsDir() {
|
||
return d
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// withSPA — middleware для раздачи SvelteKit SPA.
|
||
// Запросы к /api/* и /ws/* проксируются в API handler.
|
||
// Остальные запросы ищут статический файл в webDir; если файл не найден,
|
||
// возвращается index.html (SPA fallback для клиентского роутинга).
|
||
func withSPA(webDir string, apiHandler http.Handler) http.Handler {
|
||
fs := http.FileServer(http.Dir(webDir))
|
||
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
path := r.URL.Path
|
||
|
||
// API и WebSocket запросы — передаём в API
|
||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
|
||
apiHandler.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
// Пробуем отдать статический файл
|
||
filePath := filepath.Join(webDir, path)
|
||
info, err := os.Stat(filePath)
|
||
|
||
// Если файл существует и не является директорией — отдаём
|
||
if err == nil && !info.IsDir() {
|
||
fs.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
// SPA fallback: index.html
|
||
indexPath := filepath.Join(webDir, "index.html")
|
||
if _, err := os.Stat(indexPath); err == nil {
|
||
http.ServeFile(w, r, indexPath)
|
||
return
|
||
}
|
||
|
||
// Если index.html тоже нет — 404
|
||
http.NotFound(w, r)
|
||
})
|
||
}
|
||
|
||
// extractHostFromGateway извлекает хост из SIP URI.
|
||
// "sip:mts-gw.lan:5060" → "mts-gw.lan"
|
||
func extractHostFromGateway(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
|
||
}
|