2026-06-25 14:27:41 +07:00

378 lines
10 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 esl реализует клиент FreeSWITCH Event Socket Library (ESL).
// Использует сырой TCP — протокол ESL прост (HTTP-подобные заголовки +
// Content-Length тело), не нужна внешняя библиотека.
//
// Клиент обеспечивает:
// - Подключение и аутентификацию
// - Синхронные команды (api)
// - Подписку на события
// - Автоматический реконнект с exponential backoff
// - Потокобезопасность (mutex на отправку команд)
package esl
import (
"bufio"
"fmt"
"io"
"log"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// Event — событие, полученное от FreeSWITCH ESL.
type Event struct {
Name string
Headers map[string]string
Body string
}
// Client — ESL-клиент для управления FreeSWITCH.
type Client struct {
host string
port int
password string
conn net.Conn
reader *bufio.Reader
mu sync.Mutex // защищает отправку команд
connected atomic.Bool
eventsCh chan *Event
closeCh chan struct{}
// Переподключение
shouldReconn atomic.Bool
backoff time.Duration
// Статистика
reconnects atomic.Int64
gatewayOps atomic.Int64
eventsRecv atomic.Int64
startTime time.Time
}
// NewClient создаёт нового ESL-клиента.
func NewClient(host string, port int, password string) *Client {
return &Client{
host: host,
port: port,
password: password,
eventsCh: make(chan *Event, 100),
closeCh: make(chan struct{}),
backoff: 1 * time.Second,
}
}
// Connect подключается к FreeSWITCH и запускает фоновый реконнект.
// Блокируется до первой успешной аутентификации.
func (c *Client) Connect() error {
c.shouldReconn.Store(true)
if err := c.tryConnect(); err != nil {
return fmt.Errorf("esl connect: %w", err)
}
return nil
}
// ConnectWithRetry подключается в фоне с бесконечными попытками.
func (c *Client) ConnectWithRetry() {
c.shouldReconn.Store(true)
for {
err := c.tryConnect()
if err == nil {
return // успех
}
if !c.shouldReconn.Load() {
return // остановка
}
log.Printf("[esl] ошибка подключения: %v, повтор через %v", err, c.backoff)
time.Sleep(c.backoff)
c.backoff *= 2
if c.backoff > 60*time.Second {
c.backoff = 60 * time.Second
}
}
}
// tryConnect выполняет одну попытку подключения и аутентификации.
func (c *Client) tryConnect() error {
c.mu.Lock()
defer c.mu.Unlock()
// Закрываем предыдущее соединение если было
if c.conn != nil {
c.conn.Close()
c.closeCh = make(chan struct{})
}
addr := fmt.Sprintf("%s:%d", c.host, c.port)
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
c.conn = conn
c.reader = bufio.NewReader(conn)
// 1. Читаем auth/request
headers, _, err := c.readMessageLocked()
if err != nil {
conn.Close()
return fmt.Errorf("чтение auth/request: %w", err)
}
if headers["Content-Type"] != "auth/request" {
conn.Close()
return fmt.Errorf("ожидался auth/request, получен %s", headers["Content-Type"])
}
// 2. Отправляем пароль
authCmd := fmt.Sprintf("auth %s\n\n", c.password)
if _, err := conn.Write([]byte(authCmd)); err != nil {
conn.Close()
return fmt.Errorf("отправка auth: %w", err)
}
// 3. Читаем auth response
headers, _, err = c.readMessageLocked()
if err != nil {
conn.Close()
return fmt.Errorf("чтение auth response: %w", err)
}
if reply := headers["Reply-Text"]; !strings.HasPrefix(reply, "+OK") {
conn.Close()
return fmt.Errorf("auth rejected: %s", reply)
}
c.connected.Store(true)
c.startTime = time.Now()
c.backoff = 1 * time.Second // сброс backoff после успешного коннекта
endpoint := fmt.Sprintf("%s:%d", c.host, c.port)
log.Printf("[esl] подключён к %s", endpoint)
return nil
}
// Disconnect отключает клиент и останавливает реконнект.
func (c *Client) Disconnect() {
c.shouldReconn.Store(false)
c.mu.Lock()
defer c.mu.Unlock()
c.connected.Store(false)
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
close(c.closeCh)
}
// IsConnected возвращает статус подключения.
func (c *Client) IsConnected() bool {
return c.connected.Load()
}
// Send отправляет синхронную команду и возвращает тело ответа.
// Пример: Send("api sofia profile external gwlist") → список gateway.
func (c *Client) Send(command string) (map[string]string, string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected.Load() || c.conn == nil {
return nil, "", fmt.Errorf("esl не подключён")
}
// Отправляем команду
fullCmd := command + "\n\n"
if _, err := c.conn.Write([]byte(fullCmd)); err != nil {
c.connected.Store(false)
return nil, "", fmt.Errorf("отправка команды: %w", err)
}
// Читаем ответ
return c.readMessageLocked()
}
// Subscribe подписывается на события ESL.
// events — имена событий для фильтра: "SOFIA::gateway_register SOFIA::gateway_unregister".
func (c *Client) Subscribe(events string) error {
cmd := fmt.Sprintf("event plain %s", events)
_, _, err := c.Send(cmd)
if err != nil {
return fmt.Errorf("подписка на события: %w", err)
}
log.Printf("[esl] подписан на события: %s", events)
return nil
}
// Events возвращает канал с входящими событиями.
func (c *Client) Events() <-chan *Event {
return c.eventsCh
}
// StartReadingEvents запускает горутину чтения асинхронных событий FS.
// Должна вызываться после Connect().
func (c *Client) StartReadingEvents() {
if !c.shouldReconn.Load() {
return
}
go c.readEventsLoop()
}
// readEventsLoop читает асинхронные сообщения (события) от FS.
func (c *Client) readEventsLoop() {
for {
select {
case <-c.closeCh:
return
default:
}
if !c.connected.Load() || c.conn == nil {
time.Sleep(500 * time.Millisecond)
continue
}
c.mu.Lock()
headers, body, err := c.readMessageLocked()
c.mu.Unlock()
if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
c.connected.Store(false)
log.Printf("[esl] соединение разорвано, реконнект...")
c.reconnects.Add(1)
c.reconnect()
continue
}
log.Printf("[esl] ошибка чтения события: %v", err)
time.Sleep(100 * time.Millisecond)
continue
}
eventName := headers["Event-Name"]
if eventName == "" {
continue // не событие (например, ответ на команду)
}
evt := &Event{
Name: eventName,
Headers: headers,
Body: body,
}
c.eventsRecv.Add(1)
select {
case c.eventsCh <- evt:
default:
log.Printf("[esl] канал событий переполнен, пропускаем %s", eventName)
}
}
}
// reconnect выполняет реконнект с backoff.
func (c *Client) reconnect() {
if !c.shouldReconn.Load() {
return
}
log.Printf("[esl] реконнект через %v...", c.backoff)
time.Sleep(c.backoff)
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.mu.Unlock()
if err := c.tryConnect(); err != nil {
log.Printf("[esl] реконнект не удался: %v", err)
c.backoff *= 2
if c.backoff > 60*time.Second {
c.backoff = 60 * time.Second
}
return
}
// Успешный реконнект
c.backoff = 1 * time.Second
log.Printf("[esl] реконнект успешен, переподписываемся на события")
// TODO: gatewaySyncAll() и resubscribe на события вызываются снаружи
}
// --- Приватные методы ---
// readMessageLocked читает одно ESL-сообщение: заголовки + тело.
// Вызывающий держит c.mu.
func (c *Client) readMessageLocked() (map[string]string, string, error) {
headers := make(map[string]string)
for {
line, err := c.reader.ReadString('\n')
if err != nil {
return nil, "", err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break // конец заголовков
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
// Читаем тело согласно Content-Length
cl := headers["Content-Length"]
if cl == "" {
return headers, "", nil
}
length, err := strconv.Atoi(cl)
if err != nil {
return headers, "", fmt.Errorf("некорректный Content-Length: %s", cl)
}
body := make([]byte, length)
if _, err := io.ReadFull(c.reader, body); err != nil {
return headers, "", fmt.Errorf("чтение тела (Content-Length=%d): %w", length, err)
}
return headers, strings.TrimSpace(string(body)), nil
}
// --- Метрики ---
// Stats возвращает статистику ESL-клиента для health endpoint.
type Stats struct {
Status string `json:"status"`
Host string `json:"host"`
Reconnects int64 `json:"reconnects"`
GatewayOps int64 `json:"gateway_ops_total"`
EventsRecv int64 `json:"events_received"`
UptimeSec int64 `json:"uptime_seconds"`
}
// GetStats возвращает текущую статистику.
func (c *Client) GetStats() Stats {
st := "connected"
if !c.connected.Load() {
st = "disconnected"
}
return Stats{
Status: st,
Host: fmt.Sprintf("%s:%d", c.host, c.port),
Reconnects: c.reconnects.Load(),
GatewayOps: c.gatewayOps.Load(),
EventsRecv: c.eventsRecv.Load(),
UptimeSec: int64(time.Since(c.startTime).Seconds()),
}
}
// IncGatewayOps увеличивает счётчик операций gateway.
func (c *Client) IncGatewayOps() {
c.gatewayOps.Add(1)
}