first commit
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package esl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventHandler — callback для обработки событий ESL.
|
||||
// Принимает имя события и заголовки, вызывается из readEventsLoop.
|
||||
type EventHandler func(eventName string, headers map[string]string, body string)
|
||||
|
||||
// StartEventLoop запускает основной цикл реконнекта и чтения событий.
|
||||
// При успешном подключении:
|
||||
// - Вызывает onConnect (например, для GatewaySyncAll)
|
||||
// - Подписывается на события SOFIA::gateway_register/unregister
|
||||
// - Запускает чтение асинхронных событий
|
||||
// При разрыве соединения — автоматический реконнект.
|
||||
func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) {
|
||||
const subscribeEvents = "SOFIA::gateway_register SOFIA::gateway_unregister SOFIA::gateway_expire"
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.Disconnect()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Пытаемся подключиться
|
||||
log.Printf("[esl] попытка подключения к %s:%d", c.host, c.port)
|
||||
if err := c.Connect(); err != nil {
|
||||
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
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Успешное подключение
|
||||
c.backoff = 1 * time.Second
|
||||
|
||||
// Подписываемся на события
|
||||
if err := c.Subscribe(subscribeEvents); err != nil {
|
||||
log.Printf("[esl] ошибка подписки: %v", err)
|
||||
c.Disconnect()
|
||||
continue
|
||||
}
|
||||
|
||||
// Вызываем callback (GatewaySyncAll)
|
||||
if onConnect != nil {
|
||||
onConnect()
|
||||
}
|
||||
|
||||
// Читаем события пока соединение живо
|
||||
readEvents := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readEvents)
|
||||
c.readEventsLoop()
|
||||
// Не ждём реконнект внутри readEventsLoop
|
||||
}()
|
||||
|
||||
// Основной цикл обработки событий
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.Disconnect()
|
||||
return
|
||||
case <-readEvents:
|
||||
// Соединение разорвалось — идём на реконнект
|
||||
log.Printf("[esl] чтение событий остановлено")
|
||||
goto reconnect
|
||||
case evt, ok := <-c.Events():
|
||||
if !ok {
|
||||
goto reconnect
|
||||
}
|
||||
if onEvent != nil {
|
||||
onEvent(evt.Name, evt.Headers, evt.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reconnect:
|
||||
c.Disconnect()
|
||||
time.Sleep(c.backoff)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package esl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GatewayAdd создаёт gateway на FreeSWITCH через Sofia профиль.
|
||||
// Возвращает nil если gateway создан или уже существует (идемпотентность).
|
||||
func (c *Client) GatewayAdd(profile, name, proxy string) error {
|
||||
cmd := fmt.Sprintf("api sofia profile %s gwadd %s %s", profile, name, proxy)
|
||||
headers, body, err := c.Send(cmd)
|
||||
c.IncGatewayOps()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[esl] GatewayAdd %s: ошибка команды: %v", name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
reply := headers["Reply-Text"]
|
||||
if strings.HasPrefix(reply, "+OK") {
|
||||
log.Printf("[esl] GatewayAdd %s: создан (%s → %s)", name, profile, proxy)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gateway уже существует — не ошибка
|
||||
if strings.Contains(body, "GATEWAY ALREADY") || strings.Contains(reply, "GATEWAY ALREADY") {
|
||||
log.Printf("[esl] GatewayAdd %s: уже существует, ок", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[esl] GatewayAdd %s: ошибка FS — Reply: %s, Body: %s", name, reply, body)
|
||||
return fmt.Errorf("GatewayAdd %s: %s (%s)", name, reply, body)
|
||||
}
|
||||
|
||||
// GatewayDelete удаляет gateway на FreeSWITCH.
|
||||
// Возвращает nil если gateway удалён или не найден (идемпотентность).
|
||||
func (c *Client) GatewayDelete(profile, name string) error {
|
||||
cmd := fmt.Sprintf("api sofia profile %s gwdel %s", profile, name)
|
||||
headers, body, err := c.Send(cmd)
|
||||
c.IncGatewayOps()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[esl] GatewayDelete %s: ошибка команды: %v", name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
reply := headers["Reply-Text"]
|
||||
if strings.HasPrefix(reply, "+OK") {
|
||||
log.Printf("[esl] GatewayDelete %s: удалён", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gateway не найден — не ошибка
|
||||
if strings.Contains(body, "GATEWAY NOT FOUND") || strings.Contains(reply, "GATEWAY NOT FOUND") {
|
||||
log.Printf("[esl] GatewayDelete %s: не найден, ок", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[esl] GatewayDelete %s: ошибка FS — Reply: %s, Body: %s", name, reply, body)
|
||||
return fmt.Errorf("GatewayDelete %s: %s (%s)", name, reply, body)
|
||||
}
|
||||
|
||||
// GatewayList возвращает список имён gateway в заданном профиле.
|
||||
func (c *Client) GatewayList(profile string) ([]string, error) {
|
||||
cmd := fmt.Sprintf("api sofia profile %s gwlist", profile)
|
||||
_, body, err := c.Send(cmd)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GatewayList: %w", err)
|
||||
}
|
||||
|
||||
// Парсим вывод gwlist — имена gateway на отдельных строках
|
||||
lines := strings.Split(body, "\n")
|
||||
var gateways []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "Name:") || strings.HasPrefix(line, "====") {
|
||||
continue
|
||||
}
|
||||
// Ищем строки вида "pulse-ingress-xxxxx sip:..."
|
||||
if strings.Contains(line, "pulse-") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) > 0 {
|
||||
gateways = append(gateways, parts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
return gateways, nil
|
||||
}
|
||||
|
||||
// GatewaySyncAll синхронизирует все ingress-транки как gateway на FS.
|
||||
// Вызывается после реконнекта.
|
||||
func (c *Client) GatewaySyncAll(profile string, gateways []GatewaySpec) {
|
||||
for _, g := range gateways {
|
||||
if err := c.GatewayAdd(profile, g.Name, g.Proxy); err != nil {
|
||||
log.Printf("[esl] GatewaySyncAll: ошибка %s: %v", g.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GatewaySpec описывает gateway для синхронизации.
|
||||
type GatewaySpec struct {
|
||||
Name string
|
||||
Proxy string
|
||||
}
|
||||
Reference in New Issue
Block a user