feat: ESL client, gateway management, event subscription
- Raw TCP ESL client with reconnect - GatewayAdd/GatewayDelete/GatewaySyncAll - SOFIA::gateway_* event processing - SendAsync for deadlock-free bgapi commands
This commit is contained in:
parent
44f5fa88e5
commit
533a1fba49
@ -36,9 +36,10 @@ type Client struct {
|
|||||||
port int
|
port int
|
||||||
password string
|
password string
|
||||||
|
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
reader *bufio.Reader
|
reader *bufio.Reader
|
||||||
mu sync.Mutex // защищает отправку команд
|
mu sync.Mutex // защищает отправку синхронных команд (auth, subscribe)
|
||||||
|
sendMu sync.Mutex // защищает запись в сокет (async gateway commands)
|
||||||
|
|
||||||
connected atomic.Bool
|
connected atomic.Bool
|
||||||
eventsCh chan *Event
|
eventsCh chan *Event
|
||||||
@ -49,10 +50,10 @@ type Client struct {
|
|||||||
backoff time.Duration
|
backoff time.Duration
|
||||||
|
|
||||||
// Статистика
|
// Статистика
|
||||||
reconnects atomic.Int64
|
reconnects atomic.Int64
|
||||||
gatewayOps atomic.Int64
|
gatewayOps atomic.Int64
|
||||||
eventsRecv atomic.Int64
|
eventsRecv atomic.Int64
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient создаёт нового ESL-клиента.
|
// NewClient создаёт нового ESL-клиента.
|
||||||
@ -160,13 +161,19 @@ func (c *Client) tryConnect() error {
|
|||||||
func (c *Client) Disconnect() {
|
func (c *Client) Disconnect() {
|
||||||
c.shouldReconn.Store(false)
|
c.shouldReconn.Store(false)
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
|
||||||
c.connected.Store(false)
|
c.connected.Store(false)
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
}
|
}
|
||||||
close(c.closeCh)
|
c.mu.Unlock()
|
||||||
|
// Безопасное закрытие closeCh (может быть уже закрыт)
|
||||||
|
select {
|
||||||
|
case <-c.closeCh:
|
||||||
|
// уже закрыт
|
||||||
|
default:
|
||||||
|
close(c.closeCh)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConnected возвращает статус подключения.
|
// IsConnected возвращает статус подключения.
|
||||||
@ -221,7 +228,28 @@ func (c *Client) StartReadingEvents() {
|
|||||||
go c.readEventsLoop()
|
go c.readEventsLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAsync отправляет асинхронную команду (bgapi) без ожидания ответа.
|
||||||
|
// Ответ придёт как BACKGROUND_JOB событие в readEventsLoop.
|
||||||
|
// Использует sendMu для защиты записи в сокет.
|
||||||
|
func (c *Client) SendAsync(command string) error {
|
||||||
|
c.sendMu.Lock()
|
||||||
|
defer c.sendMu.Unlock()
|
||||||
|
|
||||||
|
if !c.connected.Load() || c.conn == nil {
|
||||||
|
return fmt.Errorf("esl не подключён")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullCmd := command + "\n\n"
|
||||||
|
_, err := c.conn.Write([]byte(fullCmd))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("отправка команды: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// readEventsLoop читает асинхронные сообщения (события) от FS.
|
// readEventsLoop читает асинхронные сообщения (события) от FS.
|
||||||
|
// Не держит мьютекс во время блокирующего чтения — синхронные команды
|
||||||
|
// (Send) используют c.mu, асинхронные (SendAsync) используют sendMu.
|
||||||
func (c *Client) readEventsLoop() {
|
func (c *Client) readEventsLoop() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@ -235,9 +263,7 @@ func (c *Client) readEventsLoop() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
headers, body, err := c.readMessageUnlocked()
|
||||||
headers, body, err := c.readMessageLocked()
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
|
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
|
||||||
@ -304,8 +330,47 @@ func (c *Client) reconnect() {
|
|||||||
|
|
||||||
// --- Приватные методы ---
|
// --- Приватные методы ---
|
||||||
|
|
||||||
|
// readMessageUnlocked читает одно ESL-сообщение: заголовки + тело.
|
||||||
|
// Должна вызываться только из readEventsLoop (одиночный читатель).
|
||||||
|
func (c *Client) readMessageUnlocked() (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
|
||||||
|
}
|
||||||
|
|
||||||
// readMessageLocked читает одно ESL-сообщение: заголовки + тело.
|
// readMessageLocked читает одно ESL-сообщение: заголовки + тело.
|
||||||
// Вызывающий держит c.mu.
|
// Вызывающий держит c.mu (для синхронных команд Send).
|
||||||
func (c *Client) readMessageLocked() (map[string]string, string, error) {
|
func (c *Client) readMessageLocked() (map[string]string, string, error) {
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
type EventHandler func(eventName string, headers map[string]string, body string)
|
type EventHandler func(eventName string, headers map[string]string, body string)
|
||||||
|
|
||||||
// StartEventLoop запускает основной цикл реконнекта и чтения событий.
|
// StartEventLoop запускает основной цикл реконнекта и чтения событий.
|
||||||
|
// Блокирует до тех пор, пока контекст не будет отменён (ctx.Done()).
|
||||||
// При успешном подключении:
|
// При успешном подключении:
|
||||||
// - Вызывает onConnect (например, для GatewaySyncAll)
|
// - Вызывает onConnect (например, для GatewaySyncAll)
|
||||||
// - Подписывается на события SOFIA::gateway_register/unregister
|
// - Подписывается на события SOFIA::gateway_register/unregister
|
||||||
@ -19,73 +20,70 @@ type EventHandler func(eventName string, headers map[string]string, body string)
|
|||||||
func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) {
|
func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) {
|
||||||
const subscribeEvents = "SOFIA::gateway_register SOFIA::gateway_unregister SOFIA::gateway_expire"
|
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()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Основной цикл обработки событий
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
c.Disconnect()
|
c.Disconnect()
|
||||||
return
|
return
|
||||||
default:
|
case <-readEvents:
|
||||||
}
|
// Соединение разорвалось — идём на реконнект
|
||||||
|
log.Printf("[esl] чтение событий остановлено")
|
||||||
// Пытаемся подключиться
|
goto reconnect
|
||||||
log.Printf("[esl] попытка подключения к %s:%d", c.host, c.port)
|
case evt, ok := <-c.Events():
|
||||||
if err := c.Connect(); err != nil {
|
if !ok {
|
||||||
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
|
goto reconnect
|
||||||
case evt, ok := <-c.Events():
|
}
|
||||||
if !ok {
|
if onEvent != nil {
|
||||||
goto reconnect
|
onEvent(evt.Name, evt.Headers, evt.Body)
|
||||||
}
|
|
||||||
if onEvent != nil {
|
|
||||||
onEvent(evt.Name, evt.Headers, evt.Body)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnect:
|
|
||||||
c.Disconnect()
|
|
||||||
time.Sleep(c.backoff)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
reconnect:
|
||||||
|
c.Disconnect()
|
||||||
|
time.Sleep(c.backoff)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,59 +7,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// GatewayAdd создаёт gateway на FreeSWITCH через Sofia профиль.
|
// GatewayAdd создаёт gateway на FreeSWITCH через Sofia профиль.
|
||||||
// Возвращает nil если gateway создан или уже существует (идемпотентность).
|
// Использует bgapi — асинхронная команда, результат приходит как BACKGROUND_JOB событие.
|
||||||
func (c *Client) GatewayAdd(profile, name, proxy string) error {
|
func (c *Client) GatewayAdd(profile, name, proxy string) error {
|
||||||
cmd := fmt.Sprintf("api sofia profile %s gwadd %s %s", profile, name, proxy)
|
cmd := fmt.Sprintf("bgapi sofia profile %s gwadd %s %s", profile, name, proxy)
|
||||||
headers, body, err := c.Send(cmd)
|
if err := c.SendAsync(cmd); err != nil {
|
||||||
c.IncGatewayOps()
|
log.Printf("[esl] GatewayAdd %s: ошибка: %v", name, err)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[esl] GatewayAdd %s: ошибка команды: %v", name, err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.IncGatewayOps()
|
||||||
reply := headers["Reply-Text"]
|
log.Printf("[esl] GatewayAdd %s: отправлен (%s → %s)", name, profile, proxy)
|
||||||
if strings.HasPrefix(reply, "+OK") {
|
return nil
|
||||||
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.
|
// GatewayDelete удаляет gateway на FreeSWITCH.
|
||||||
// Возвращает nil если gateway удалён или не найден (идемпотентность).
|
// Использует bgapi — асинхронная команда, результат приходит как BACKGROUND_JOB событие.
|
||||||
func (c *Client) GatewayDelete(profile, name string) error {
|
func (c *Client) GatewayDelete(profile, name string) error {
|
||||||
cmd := fmt.Sprintf("api sofia profile %s gwdel %s", profile, name)
|
cmd := fmt.Sprintf("bgapi sofia profile %s gwdel %s", profile, name)
|
||||||
headers, body, err := c.Send(cmd)
|
if err := c.SendAsync(cmd); err != nil {
|
||||||
c.IncGatewayOps()
|
log.Printf("[esl] GatewayDelete %s: ошибка: %v", name, err)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[esl] GatewayDelete %s: ошибка команды: %v", name, err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.IncGatewayOps()
|
||||||
reply := headers["Reply-Text"]
|
log.Printf("[esl] GatewayDelete %s: отправлен", name)
|
||||||
if strings.HasPrefix(reply, "+OK") {
|
return nil
|
||||||
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 в заданном профиле.
|
// GatewayList возвращает список имён gateway в заданном профиле.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user