first commit
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// Package api реализует HTTP API pulse-lets-go.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
)
|
||||
|
||||
// JWT claims
|
||||
type claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// contextKey — тип для ключей context.
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
userContextKey contextKey = "user"
|
||||
refreshTokenKey contextKey = "refresh_token"
|
||||
)
|
||||
|
||||
// UserInfo — информация о пользователе, извлекаемая из JWT.
|
||||
type UserInfo struct {
|
||||
UserID string
|
||||
Username string
|
||||
Role string
|
||||
}
|
||||
|
||||
const (
|
||||
jwtExpiry = 15 * time.Minute
|
||||
refreshExpiry = 24 * time.Hour
|
||||
)
|
||||
|
||||
// generateJWT создаёт access+refresh токены.
|
||||
func (a *API) generateJWT(user *models.User) (*models.LoginResponse, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Access token
|
||||
accessClaims := claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: string(user.Role),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(jwtExpiry)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(a.jwtSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
refreshClaims := claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: string(user.Role),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(refreshExpiry)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(a.jwtSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int64(jwtExpiry.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// authMiddleware — проверяет JWT в заголовке Authorization.
|
||||
func (a *API) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := extractBearerToken(r)
|
||||
if tokenStr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "требуется авторизация")
|
||||
return
|
||||
}
|
||||
|
||||
c := &claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(a.jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
writeError(w, http.StatusUnauthorized, "недействительный токен")
|
||||
return
|
||||
}
|
||||
|
||||
user := &UserInfo{
|
||||
UserID: c.UserID,
|
||||
Username: c.Username,
|
||||
Role: c.Role,
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// adminOnly — middleware проверки роли admin.
|
||||
func (a *API) adminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUserFromContext(r.Context())
|
||||
if user == nil || user.Role != string(models.RoleAdmin) {
|
||||
writeError(w, http.StatusForbidden, "требуется роль admin")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// apiKeyMiddleware — проверяет API-key для monitoring endpoint.
|
||||
func (a *API) apiKeyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Header.Get("X-API-Key")
|
||||
if key == "" || key != a.monitoringAPIKey {
|
||||
writeError(w, http.StatusUnauthorized, "недействительный API-ключ")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// handleLogin обрабатывает POST /api/auth/login.
|
||||
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "имя и пароль обязательны")
|
||||
return
|
||||
}
|
||||
|
||||
users, err := a.configManager.ReadUsers()
|
||||
if err != nil {
|
||||
log.Printf("[auth] ошибка чтения users.json: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сервера")
|
||||
return
|
||||
}
|
||||
|
||||
var found *models.User
|
||||
for i := range users {
|
||||
if users[i].Username == req.Username {
|
||||
found = &users[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found == nil {
|
||||
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
|
||||
return
|
||||
}
|
||||
|
||||
// Если пароль не задан (плейсхолдер), разрешаем вход с паролем "admin"
|
||||
if found.PasswordHash == "" {
|
||||
if req.Password != "admin" {
|
||||
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
|
||||
return
|
||||
}
|
||||
// Хешируем пароль и сохраняем
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
found.PasswordHash = string(hash)
|
||||
a.configManager.SaveUsers(users)
|
||||
} else {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(found.PasswordHash), []byte(req.Password)); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := a.generateJWT(found)
|
||||
if err != nil {
|
||||
log.Printf("[auth] ошибка генерации JWT: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сервера")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleRefresh выдаёт новый access токен по refresh токену.
|
||||
func (a *API) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := extractBearerToken(r)
|
||||
if tokenStr == "" {
|
||||
// пробуем получить из query param (для WebSocket)
|
||||
tokenStr = r.URL.Query().Get("token")
|
||||
}
|
||||
if tokenStr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "требуется токен")
|
||||
return
|
||||
}
|
||||
|
||||
c := &claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(a.jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
writeError(w, http.StatusUnauthorized, "недействительный refresh токен")
|
||||
return
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
ID: c.UserID,
|
||||
Username: c.Username,
|
||||
Role: models.UserRole(c.Role),
|
||||
}
|
||||
|
||||
resp, err := a.generateJWT(user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сервера")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// --- Хелперы ---
|
||||
|
||||
// extractBearerToken извлекает JWT из заголовка Authorization: Bearer <token>.
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
// GetUserFromContext извлекает UserInfo из контекста запроса.
|
||||
func GetUserFromContext(ctx context.Context) *UserInfo {
|
||||
user, ok := ctx.Value(userContextKey).(*UserInfo)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// writeJSON отправляет JSON-ответ.
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError отправляет JSON-ошибку.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleHealth — GET /api/health — возвращает статус всех зависимостей.
|
||||
func (a *API) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
stats := a.engine.GetHealthStats()
|
||||
|
||||
natsStatus := "disconnected"
|
||||
if a.natsConnected != nil && a.natsConnected() {
|
||||
natsStatus = "connected"
|
||||
}
|
||||
|
||||
eslInfo := map[string]interface{}{"status": "disabled"}
|
||||
if a.eslClient != nil {
|
||||
eslStats := a.eslClient.GetStats()
|
||||
eslInfo = map[string]interface{}{
|
||||
"status": eslStats.Status,
|
||||
"host": eslStats.Host,
|
||||
"uptime_seconds": eslStats.UptimeSec,
|
||||
"reconnects": eslStats.Reconnects,
|
||||
"gateway_ops": eslStats.GatewayOps,
|
||||
"events_recv": eslStats.EventsRecv,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"connections": map[string]interface{}{
|
||||
"nats": natsStatus,
|
||||
"esl": eslInfo,
|
||||
},
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// handleLiveness — GET /api/health/live — всегда 200, если процесс жив.
|
||||
func (a *API) handleLiveness(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "alive"})
|
||||
}
|
||||
|
||||
// handleReadiness — GET /api/health/ready — 200 когда NATS подключён, метрики поступают.
|
||||
// ESL опционален — не требуется для готовности.
|
||||
func (a *API) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
if a.natsConnected != nil && !a.natsConnected() {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"status": "not_ready",
|
||||
"reason": "nats_disconnected",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !a.engine.HasRecentMetrics(60) {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"status": "not_ready",
|
||||
"reason": "no_recent_metrics",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// requestLoggingMiddleware логирует каждый HTTP-запрос с Request ID, методом, путём, статусом и длительностью.
|
||||
func requestLoggingMiddleware(logFormat string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Не логируем health-check запросы и OPTIONS
|
||||
if r.URL.Path == "/api/health/live" || r.URL.Path == "/api/health/ready" || r.Method == "OPTIONS" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
reqID := generateRequestID()
|
||||
r.Header.Set("X-Request-ID", reqID)
|
||||
w.Header().Set("X-Request-ID", reqID)
|
||||
|
||||
start := time.Now()
|
||||
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(lrw, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
// Извлекаем пользователя из контекста (может быть nil)
|
||||
userStr := "-"
|
||||
if user := GetUserFromContext(r.Context()); user != nil {
|
||||
userStr = user.Username
|
||||
}
|
||||
|
||||
switch logFormat {
|
||||
case "json":
|
||||
entry := map[string]interface{}{
|
||||
"ts": start.UTC().Format(time.RFC3339),
|
||||
"id": reqID,
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"status": lrw.statusCode,
|
||||
"duration_ms": float64(duration.Microseconds()) / 1000.0,
|
||||
"remote_addr": r.RemoteAddr,
|
||||
"user": userStr,
|
||||
}
|
||||
data, _ := json.Marshal(entry)
|
||||
log.Printf("%s", string(data))
|
||||
default:
|
||||
log.Printf("[%s] %s %s %d %.2fms %s user=%s",
|
||||
reqID, r.Method, r.URL.Path, lrw.statusCode,
|
||||
float64(duration.Microseconds())/1000.0, r.RemoteAddr, userStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID генерирует случайный Request ID: req-XXXXXXXX.
|
||||
func generateRequestID() string {
|
||||
b := make([]byte, 6)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "req-" + hex.EncodeToString([]byte{0, 0, 0, 0, 0, 0})
|
||||
}
|
||||
return "req-" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// loggingResponseWriter оборачивает http.ResponseWriter для перехвата статус-кода.
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (w *loggingResponseWriter) WriteHeader(code int) {
|
||||
w.statusCode = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *loggingResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
)
|
||||
|
||||
// handleZabbix — GET /api/monitoring/zabbix — возвращает JSON для Zabbix LLD + items.
|
||||
func (a *API) handleZabbix(w http.ResponseWriter, r *http.Request) {
|
||||
nodes := a.engine.GetAllNodes()
|
||||
|
||||
data := make([]models.ZabbixNode, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
data = append(data, models.ZabbixNode{
|
||||
NodeID: n.NodeID,
|
||||
Status: n.Status,
|
||||
ActiveCalls: n.ActiveCalls,
|
||||
MaxCalls: n.MaxCalls,
|
||||
IdleCPU: n.IdleCPU,
|
||||
LoadAvg: n.LoadAvg,
|
||||
CallFailureRate: n.CallFailureRate,
|
||||
Score: n.Score,
|
||||
Disabled: n.Disabled,
|
||||
IsStale: n.IsStale,
|
||||
SecondsAgo: n.SecondsAgo,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, models.ZabbixResponse{Data: data})
|
||||
}
|
||||
|
||||
// handlePrometheus — GET /api/monitoring/prometheus — text/plain метрики для Prometheus.
|
||||
func (a *API) handlePrometheus(w http.ResponseWriter, r *http.Request) {
|
||||
nodes := a.engine.GetAllNodes()
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
for _, n := range nodes {
|
||||
id := sanitizePromLabel(n.NodeID)
|
||||
|
||||
fmt.Fprintf(&sb, "pulse_node_active_calls{node=\"%s\"} %d\n", id, n.ActiveCalls)
|
||||
fmt.Fprintf(&sb, "pulse_node_max_calls{node=\"%s\"} %d\n", id, n.MaxCalls)
|
||||
fmt.Fprintf(&sb, "pulse_node_idle_cpu{node=\"%s\"} %.2f\n", id, n.IdleCPU)
|
||||
fmt.Fprintf(&sb, "pulse_node_load_avg{node=\"%s\"} %.2f\n", id, n.LoadAvg)
|
||||
fmt.Fprintf(&sb, "pulse_node_call_failure_rate{node=\"%s\"} %.2f\n", id, n.CallFailureRate)
|
||||
fmt.Fprintf(&sb, "pulse_node_score{node=\"%s\"} %.2f\n", id, n.Score)
|
||||
fmt.Fprintf(&sb, "pulse_node_seconds_ago{node=\"%s\"} %.2f\n", id, n.SecondsAgo)
|
||||
|
||||
disabledVal := 0
|
||||
staleVal := 0
|
||||
statusVal := 1 // ok
|
||||
if n.Disabled {
|
||||
disabledVal = 1
|
||||
}
|
||||
if n.IsStale {
|
||||
staleVal = 1
|
||||
}
|
||||
if n.Status != "ok" {
|
||||
statusVal = 0
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "pulse_node_disabled{node=\"%s\"} %d\n", id, disabledVal)
|
||||
fmt.Fprintf(&sb, "pulse_node_stale{node=\"%s\"} %d\n", id, staleVal)
|
||||
fmt.Fprintf(&sb, "pulse_node_status_ok{node=\"%s\"} %d\n", id, statusVal)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(sb.String()))
|
||||
}
|
||||
|
||||
// sanitizePromLabel заменяет символы, недопустимые в Prometheus-лейблах.
|
||||
func sanitizePromLabel(s string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(s, ".", "_"), "-", "_")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
)
|
||||
|
||||
// handleGetNodes — GET /api/nodes — список всех нод с текущими score.
|
||||
func (a *API) handleGetNodes(w http.ResponseWriter, r *http.Request) {
|
||||
nodes := a.engine.GetAllNodes()
|
||||
writeJSON(w, http.StatusOK, nodes)
|
||||
}
|
||||
|
||||
// handleGetNodeMetrics — GET /api/nodes/{id}/metrics — история метрик ноды из ring buffer.
|
||||
func (a *API) handleGetNodeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
nodeID := r.PathValue("id")
|
||||
if nodeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "не указан id ноды")
|
||||
return
|
||||
}
|
||||
|
||||
snapshots := a.engine.GetNodeHistory(nodeID)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"node_id": nodeID,
|
||||
"snapshots": snapshots,
|
||||
"count": len(snapshots),
|
||||
})
|
||||
}
|
||||
|
||||
// handleToggleNode — PUT /api/nodes/{id}/toggle — включение/выключение ноды.
|
||||
func (a *API) handleToggleNode(w http.ResponseWriter, r *http.Request) {
|
||||
nodeID := r.PathValue("id")
|
||||
if nodeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "не указан id ноды")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.ToggleNodeRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.engine.ToggleNode(nodeID, req.Disabled, req.Reason); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
info, _ := a.engine.GetNodeInfo(nodeID)
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter — in-memory token bucket rate limiter.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*tokenBucket
|
||||
rate float64 // токенов в секунду
|
||||
burst int // максимальный размер бакета
|
||||
cleanupInterval time.Duration
|
||||
lastCleanup time.Time
|
||||
}
|
||||
|
||||
type tokenBucket struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// newRateLimiter создаёт rate limiter с заданными параметрами.
|
||||
func newRateLimiter(ratePerSec, burst int) *rateLimiter {
|
||||
if burst <= 0 {
|
||||
burst = ratePerSec
|
||||
}
|
||||
return &rateLimiter{
|
||||
buckets: make(map[string]*tokenBucket),
|
||||
rate: float64(ratePerSec),
|
||||
burst: burst,
|
||||
cleanupInterval: 60 * time.Second,
|
||||
lastCleanup: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// allow проверяет, разрешён ли запрос для данного ключа.
|
||||
// Возвращает true если разрешён, false если превышен лимит.
|
||||
func (rl *rateLimiter) allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Периодическая очистка устаревших бакетов
|
||||
if now.Sub(rl.lastCleanup) > rl.cleanupInterval {
|
||||
for k, b := range rl.buckets {
|
||||
if now.Sub(b.lastSeen) > rl.cleanupInterval {
|
||||
delete(rl.buckets, k)
|
||||
}
|
||||
}
|
||||
rl.lastCleanup = now
|
||||
}
|
||||
|
||||
b, exists := rl.buckets[key]
|
||||
if !exists {
|
||||
// Новый бакет с полным запасом токенов
|
||||
b = &tokenBucket{tokens: float64(rl.burst), lastSeen: now}
|
||||
rl.buckets[key] = b
|
||||
b.tokens-- // расходуем один токен
|
||||
return true
|
||||
}
|
||||
|
||||
// Пополняем токены
|
||||
elapsed := now.Sub(b.lastSeen).Seconds()
|
||||
b.tokens += elapsed * rl.rate
|
||||
if b.tokens > float64(rl.burst) {
|
||||
b.tokens = float64(rl.burst)
|
||||
}
|
||||
b.lastSeen = now
|
||||
|
||||
if b.tokens >= 1.0 {
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rateLimitMiddleware создаёт middleware с rate limiting по IP.
|
||||
// Ключ — RemoteAddr (или X-Forwarded-For, если за проксей).
|
||||
func rateLimitMiddleware(limiter *rateLimiter) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := stripPort(r.RemoteAddr)
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
key = fwd
|
||||
}
|
||||
|
||||
if !limiter.allow(key) {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
writeError(w, http.StatusTooManyRequests, "слишком много запросов")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stripPort убирает порт из адреса (напр. "[::1]:58544" → "::1", "127.0.0.1:1234" → "127.0.0.1").
|
||||
func stripPort(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr // нет порта
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
)
|
||||
|
||||
// handleRoute — GET /api/route — главный эндпоинт маршрутизации вызова.
|
||||
func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
|
||||
a.engine.IncrementRouteRequests()
|
||||
|
||||
nodeID, score, fallback := a.engine.GetBestNode()
|
||||
|
||||
// Нет зарегистрированных нод
|
||||
if nodeID == "" {
|
||||
writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{
|
||||
Error: "no_nodes_registered",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Все ноды unhealthy — fallback
|
||||
if fallback || score < 0 {
|
||||
fallbackGW, _ := a.findFallbackGateway()
|
||||
nodes := a.getRouteNodeInfo()
|
||||
|
||||
writeJSON(w, http.StatusOK, models.RouteResponse{
|
||||
Fallback: true,
|
||||
SIPGateway: fallbackGW,
|
||||
Reason: "all_nodes_unhealthy",
|
||||
Nodes: nodes,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Нормальный маршрут
|
||||
gw, ok := a.findBalanceGateway(nodeID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "gateway не найден для ноды")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, models.RouteResponse{
|
||||
NodeID: nodeID,
|
||||
Score: score,
|
||||
SIPGateway: gw,
|
||||
})
|
||||
}
|
||||
|
||||
// findBalanceGateway ищет gateway для balance-транка, привязанного к nodeID.
|
||||
func (a *API) findBalanceGateway(nodeID string) (string, bool) {
|
||||
trunks, err := a.configManager.ReadTrunks()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, t := range trunks {
|
||||
if t.Type == models.TrunkBalance && t.NodeID == nodeID && t.Enabled {
|
||||
return t.Gateway, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// findFallbackGateway ищет gateway для fallback-транка.
|
||||
func (a *API) findFallbackGateway() (string, bool) {
|
||||
trunks, err := a.configManager.ReadTrunks()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, t := range trunks {
|
||||
if t.Type == models.TrunkFallback && t.Enabled {
|
||||
return t.Gateway, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// getRouteNodeInfo возвращает список нод с их скорами для ответа маршрутизации.
|
||||
func (a *API) getRouteNodeInfo() []models.RouteNodeInfo {
|
||||
nodes := a.engine.GetAllNodes()
|
||||
result := make([]models.RouteNodeInfo, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
info := models.RouteNodeInfo{
|
||||
ID: n.NodeID,
|
||||
Score: n.Score,
|
||||
Reason: n.LethalReason,
|
||||
Status: n.Status,
|
||||
}
|
||||
if n.Disabled {
|
||||
info.Status = "disabled"
|
||||
} else if n.IsStale {
|
||||
info.Status = "stale"
|
||||
} else if n.Score < 0 {
|
||||
info.Status = "unhealthy"
|
||||
} else {
|
||||
info.Status = "healthy"
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pulse-lets-go/internal/config"
|
||||
"github.com/pulse-lets-go/internal/engine"
|
||||
"github.com/pulse-lets-go/internal/esl"
|
||||
)
|
||||
|
||||
// API — главная структура HTTP API, агрегирует все зависимости.
|
||||
type API struct {
|
||||
engine *engine.Engine
|
||||
configManager *config.Manager
|
||||
jwtSecret string
|
||||
monitoringAPIKey string
|
||||
wsHub *wsHub
|
||||
natsConnected func() bool // колбэк для проверки NATS-статуса (health)
|
||||
eslClient *esl.Client // ESL-клиент (nil если не сконфигурирован)
|
||||
logFormat string
|
||||
routeLimiter *rateLimiter
|
||||
apiLimiter *rateLimiter
|
||||
}
|
||||
|
||||
// NewAPI создаёт новый HTTP API с заданными зависимостями.
|
||||
func NewAPI(eng *engine.Engine, cfgMgr *config.Manager, jwtSecret, monitoringAPIKey string, natsFn func() bool, cfg *config.Config) *API {
|
||||
a := &API{
|
||||
engine: eng,
|
||||
configManager: cfgMgr,
|
||||
jwtSecret: jwtSecret,
|
||||
monitoringAPIKey: monitoringAPIKey,
|
||||
wsHub: newWSHub(),
|
||||
natsConnected: natsFn,
|
||||
logFormat: cfg.Log.Format,
|
||||
}
|
||||
if cfg.RateLimit.Enabled {
|
||||
a.routeLimiter = newRateLimiter(cfg.RateLimit.RoutePerSec, cfg.RateLimit.RoutePerSec)
|
||||
a.apiLimiter = newRateLimiter(cfg.RateLimit.APIPerSec, cfg.RateLimit.APIPerSec)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SetESLClient устанавливает ESL-клиент (вызывается после инициализации ESL).
|
||||
func (a *API) SetESLClient(client *esl.Client) {
|
||||
a.eslClient = client
|
||||
}
|
||||
|
||||
// Handler возвращает http.Handler со всеми зарегистрированными маршрутами.
|
||||
func (a *API) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// --- Health endpoints (без авторизации) ---
|
||||
|
||||
mux.HandleFunc("GET /api/health", a.handleHealth)
|
||||
mux.HandleFunc("GET /api/health/live", a.handleLiveness)
|
||||
mux.HandleFunc("GET /api/health/ready", a.handleReadiness)
|
||||
|
||||
// --- Публичные маршруты (без авторизации) ---
|
||||
|
||||
mux.HandleFunc("POST /api/auth/login", a.handleLogin)
|
||||
mux.HandleFunc("POST /api/auth/refresh", a.handleRefresh)
|
||||
mux.HandleFunc("GET /api/route", a.handleRoute)
|
||||
mux.HandleFunc("GET /ws/metrics", a.handleWS)
|
||||
|
||||
// Monitoring с API-key (без JWT)
|
||||
mux.Handle("GET /api/monitoring/zabbix", a.apiKeyMiddleware(http.HandlerFunc(a.handleZabbix)))
|
||||
mux.Handle("GET /api/monitoring/prometheus", a.apiKeyMiddleware(http.HandlerFunc(a.handlePrometheus)))
|
||||
|
||||
// --- Админка: Nodes (admin + viewer) ---
|
||||
|
||||
mux.Handle("GET /api/nodes", a.authMiddleware(http.HandlerFunc(a.handleGetNodes)))
|
||||
mux.Handle("GET /api/nodes/{id}/metrics", a.authMiddleware(http.HandlerFunc(a.handleGetNodeMetrics)))
|
||||
mux.Handle("PUT /api/nodes/{id}/toggle", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleToggleNode))))
|
||||
|
||||
// --- Админка: Trunks (admin only) ---
|
||||
|
||||
mux.Handle("GET /api/trunks", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleGetTrunks))))
|
||||
mux.Handle("POST /api/trunks", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleCreateTrunk))))
|
||||
mux.Handle("PUT /api/trunks/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateTrunk))))
|
||||
mux.Handle("DELETE /api/trunks/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteTrunk))))
|
||||
|
||||
// --- Админка: Users (admin only) ---
|
||||
|
||||
mux.Handle("GET /api/users", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleGetUsers))))
|
||||
mux.Handle("POST /api/users", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleCreateUser))))
|
||||
mux.Handle("PUT /api/users/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateUser))))
|
||||
mux.Handle("DELETE /api/users/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteUser))))
|
||||
|
||||
// Логирование + CORS (навешиваем после mux)
|
||||
var wrapped http.Handler = mux
|
||||
wrapped = requestLoggingMiddleware(a.logFormat)(wrapped)
|
||||
|
||||
// Rate limiting
|
||||
if a.routeLimiter != nil && a.apiLimiter != nil {
|
||||
handlerBeforeRL := wrapped
|
||||
wrapped = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/route" {
|
||||
rateLimitMiddleware(a.routeLimiter)(handlerBeforeRL).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if len(r.URL.Path) > 5 && r.URL.Path[:5] == "/api/" {
|
||||
rateLimitMiddleware(a.apiLimiter)(handlerBeforeRL).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
handlerBeforeRL.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
return corsMiddleware(wrapped)
|
||||
}
|
||||
|
||||
// corsMiddleware добавляет CORS-заголовки.
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// decodeJSON декодирует тело запроса в структуру v.
|
||||
func decodeJSON(r *http.Request, v interface{}) error {
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
return fmt.Errorf("декодирование JSON: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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) {
|
||||
profile = a.readConfig().ESL.SofiaProfile
|
||||
name = fmt.Sprintf("pulse-ingress-%s", 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
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// handleGetUsers — GET /api/users — список пользователей.
|
||||
func (a *API) handleGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := a.configManager.ReadUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения пользователей")
|
||||
return
|
||||
}
|
||||
|
||||
// Не возвращаем хэши паролей
|
||||
type safeUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role models.UserRole `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
result := make([]safeUser, len(users))
|
||||
for i, u := range users {
|
||||
result[i] = safeUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleCreateUser — POST /api/users — создание пользователя.
|
||||
func (a *API) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.CreateUserRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "имя и пароль обязательны")
|
||||
return
|
||||
}
|
||||
if req.Role != models.RoleAdmin && req.Role != models.RoleViewer {
|
||||
writeError(w, http.StatusBadRequest, "роль должна быть admin или viewer")
|
||||
return
|
||||
}
|
||||
|
||||
users, err := a.configManager.ReadUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения пользователей")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка уникальности имени
|
||||
for _, u := range users {
|
||||
if u.Username == req.Username {
|
||||
writeError(w, http.StatusConflict, "пользователь с таким именем уже существует")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка хеширования пароля")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
user := models.User{
|
||||
ID: generateID("user"),
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hash),
|
||||
Role: req.Role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
users = append(users, user)
|
||||
if err := a.configManager.SaveUsers(users); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сохранения пользователя")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]string{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": string(user.Role),
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateUser — PUT /api/users/{id} — обновление пользователя.
|
||||
func (a *API) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.PathValue("id")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "не указан id пользователя")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||
return
|
||||
}
|
||||
|
||||
users, err := a.configManager.ReadUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения пользователей")
|
||||
return
|
||||
}
|
||||
|
||||
idx := findUserIndex(users, userID)
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusNotFound, "пользователь не найден")
|
||||
return
|
||||
}
|
||||
|
||||
u := &users[idx]
|
||||
if req.Username != nil {
|
||||
// Проверка уникальности
|
||||
for i, other := range users {
|
||||
if other.Username == *req.Username && i != idx {
|
||||
writeError(w, http.StatusConflict, "пользователь с таким именем уже существует")
|
||||
return
|
||||
}
|
||||
}
|
||||
u.Username = *req.Username
|
||||
}
|
||||
if req.Password != nil {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
u.PasswordHash = string(hash)
|
||||
}
|
||||
if req.Role != nil {
|
||||
if *req.Role != models.RoleAdmin && *req.Role != models.RoleViewer {
|
||||
writeError(w, http.StatusBadRequest, "роль должна быть admin или viewer")
|
||||
return
|
||||
}
|
||||
// Защита первичного администратора от понижения роли
|
||||
if userID == "user-01" && *req.Role != models.RoleAdmin {
|
||||
writeError(w, http.StatusConflict, "роль первичного администратора нельзя изменить")
|
||||
return
|
||||
}
|
||||
u.Role = *req.Role
|
||||
}
|
||||
u.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := a.configManager.SaveUsers(users); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сохранения пользователя")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"role": string(u.Role),
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteUser — DELETE /api/users/{id} — удаление пользователя.
|
||||
func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.PathValue("id")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "не указан id пользователя")
|
||||
return
|
||||
}
|
||||
|
||||
users, err := a.configManager.ReadUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения пользователей")
|
||||
return
|
||||
}
|
||||
|
||||
idx := findUserIndex(users, userID)
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusNotFound, "пользователь не найден")
|
||||
return
|
||||
}
|
||||
|
||||
// Защита первичного администратора от удаления
|
||||
if userID == "user-01" {
|
||||
writeError(w, http.StatusConflict, "первичного администратора нельзя удалить")
|
||||
return
|
||||
}
|
||||
|
||||
// Не даём удалить последнего админа
|
||||
adminCount := 0
|
||||
for _, u := range users {
|
||||
if u.Role == models.RoleAdmin {
|
||||
adminCount++
|
||||
}
|
||||
}
|
||||
if users[idx].Role == models.RoleAdmin && adminCount <= 1 {
|
||||
writeError(w, http.StatusConflict, "нельзя удалить последнего администратора")
|
||||
return
|
||||
}
|
||||
|
||||
users = append(users[:idx], users[idx+1:]...)
|
||||
if err := a.configManager.SaveUsers(users); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сохранения пользователей")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// findUserIndex находит индекс пользователя по ID.
|
||||
func findUserIndex(users []models.User, id string) int {
|
||||
for i := range users {
|
||||
if users[i].ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// generateID генерирует ID вида "prefix-XXXXX".
|
||||
func generateID(prefix string) string {
|
||||
return fmt.Sprintf("%s-%s", prefix, 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]
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Разрешаем все origins для dev
|
||||
},
|
||||
}
|
||||
|
||||
// wsClient — соединение WebSocket одного клиента.
|
||||
type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// wsHub управляет подключёнными WebSocket-клиентами и рассылает метрики.
|
||||
type wsHub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*wsClient]bool
|
||||
}
|
||||
|
||||
// newWSHub создаёт хаб WebSocket.
|
||||
func newWSHub() *wsHub {
|
||||
return &wsHub{
|
||||
clients: make(map[*wsClient]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// add добавляет клиента в хаб.
|
||||
func (h *wsHub) add(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = true
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// remove удаляет клиента из хаба.
|
||||
func (h *wsHub) remove(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
if h.clients[c] {
|
||||
c.conn.Close()
|
||||
delete(h.clients, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// broadcast рассылает метрики всем подключённым клиентам.
|
||||
func (h *wsHub) broadcast(msg *models.WsMetricsMessage) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.clients {
|
||||
client.mu.Lock()
|
||||
err := client.conn.WriteJSON(msg)
|
||||
client.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("[ws] ошибка отправки: %v", err)
|
||||
go h.remove(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleWS — WebSocket /ws/metrics.
|
||||
func (a *API) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
// пробуем заголовок
|
||||
token = extractBearerToken(r)
|
||||
}
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "требуется токен")
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация JWT
|
||||
userInfo, err := a.validateToken(token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "недействительный токен")
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[ws] ошибка upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &wsClient{conn: conn}
|
||||
a.wsHub.add(client)
|
||||
|
||||
log.Printf("[ws] клиент подключён: %s (%s)", userInfo.Username, r.RemoteAddr)
|
||||
|
||||
// Отправляем текущее состояние при подключении
|
||||
go func() {
|
||||
nodes := a.engine.GetAllNodes()
|
||||
msg := &models.WsMetricsMessage{
|
||||
Type: "nodes_update",
|
||||
Payload: nodes,
|
||||
}
|
||||
a.wsHub.broadcast(msg)
|
||||
}()
|
||||
|
||||
// Читаем из вебсокета (ping/pong + закрытие)
|
||||
go func() {
|
||||
defer a.wsHub.remove(client)
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("[ws] клиент отключён: %s", r.RemoteAddr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// BroadcastMetrics рассылает метрики всем WS-клиентам (вызывается engine при обновлении).
|
||||
func (a *API) BroadcastMetrics() {
|
||||
nodes := a.engine.GetAllNodes()
|
||||
msg := &models.WsMetricsMessage{
|
||||
Type: "nodes_update",
|
||||
Payload: nodes,
|
||||
}
|
||||
a.wsHub.broadcast(msg)
|
||||
}
|
||||
|
||||
// validateToken проверяет JWT и возвращает UserInfo.
|
||||
func (a *API) validateToken(tokenStr string) (*UserInfo, error) {
|
||||
token, err := parseJWT(tokenStr, a.jwtSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// parseJWT парсит JWT токен.
|
||||
func parseJWT(tokenStr string, secret string) (*UserInfo, error) {
|
||||
c := &claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, fmt.Errorf("недействительный токен: %w", err)
|
||||
}
|
||||
return &UserInfo{
|
||||
UserID: c.UserID,
|
||||
Username: c.Username,
|
||||
Role: c.Role,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user