first commit

This commit is contained in:
Maksim Totmin
2026-06-25 14:27:41 +07:00
commit 44f5fa88e5
44 changed files with 8664 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
// 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/nats"
)
func main() {
// Определяем рабочую директорию
exe, _ := os.Executable()
exeDir := filepath.Dir(exe)
dataDir := filepath.Join(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()
// 6. HTTP API
apiHandler := api.NewAPI(eng, cfgMgr, cfg.JWTSecret, 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) {
log.Printf("[esl] событие: %s", eventName)
},
)
cancel()
}()
}
// ESL shutdown при завершении main
defer func() {
if eslClient != nil {
eslClient.Disconnect()
}
}()
// Подключаем раздачу 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)
}
sub.Close()
metricsLogger.Close()
log.Println("[main] pulse-lets-go остановлен")
}
// findWebDir ищет директорию со SvelteKit сборкой (web/build/).
// Пробует по порядку: переменная окружения, рядом с бинарником, в корне проекта, CWD.
func findWebDir(exeDir string) string {
candidates := []string{
os.Getenv("PULSE_WEB_DIR"),
filepath.Join(exeDir, "web", "build"), // bin/web/build
filepath.Join(filepath.Dir(exeDir), "web", "build"), // ../web/build (корень проекта)
"web/build", // ./web/build (CWD)
}
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
}