329 lines
11 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.

// 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
}