Skip to content

Commit

Permalink
Merge pull request #8 from percipia/options
Browse files Browse the repository at this point in the history
Configurable Option Support
winsock authored Mar 31, 2021
2 parents 6e56fc1 + 2b5623c commit 8a4ed26
Showing 9 changed files with 158 additions and 40 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ eslgo was written from the ground up in idiomatic Go for use in our production p
go get github.com/percipia/eslgo
```
```
github.com/percipia/eslgo v1.3.3
github.com/percipia/eslgo v1.4.0
```

## Overview
1 change: 1 addition & 0 deletions command/command.go
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@
*/
package command

// Command - A basic interface for FreeSWITCH ESL commands. Implement this if you want to send your own raw data to FreeSIWTCH over the ESL connection. Do not add the eslgo.EndOfMessage(\r\n\r\n) marker, eslgo does that for you.
type Command interface {
BuildMessage() string
}
41 changes: 34 additions & 7 deletions connection.go
Original file line number Diff line number Diff line change
@@ -16,7 +16,6 @@ import (
"errors"
"github.com/google/uuid"
"github.com/percipia/eslgo/command"
"log"
"net"
"net/textproto"
"sync"
@@ -35,16 +34,37 @@ type Conn struct {
eventListenerLock sync.RWMutex
eventListeners map[string]map[string]EventListener
outbound bool
logger Logger
exitTimeout time.Duration
closeOnce sync.Once
}

// Options - Generic options for an ESL connection, either inbound or outbound
type Options struct {
Context context.Context // This specifies the base running context for the connection. If this context expires all connections will be terminated.
Logger Logger // This specifies the logger to be used for any library internal messages. Can be set to nil to suppress everything.
ExitTimeout time.Duration // How long should we wait for FreeSWITCH to respond to our "exit" command. 5 seconds is a sane default.
}

// DefaultOptions - The default options used for creating the connection
var DefaultOptions = Options{
Context: context.Background(),
Logger: NormalLogger{},
ExitTimeout: 5 * time.Second,
}

const EndOfMessage = "\r\n\r\n"

func newConnection(c net.Conn, outbound bool) *Conn {
func newConnection(c net.Conn, outbound bool, opts Options) *Conn {
reader := bufio.NewReader(c)
header := textproto.NewReader(reader)

runningContext, stop := context.WithCancel(context.Background())
// If logger is nil, do not actually output anything
if opts.Logger == nil {
opts.Logger = NilLogger{}
}

runningContext, stop := context.WithCancel(opts.Context)

instance := &Conn{
conn: c,
@@ -63,12 +83,15 @@ func newConnection(c net.Conn, outbound bool) *Conn {
stopFunc: stop,
eventListeners: make(map[string]map[string]EventListener),
outbound: outbound,
logger: opts.Logger,
exitTimeout: opts.ExitTimeout,
}
go instance.receiveLoop()
go instance.eventLoop()
return instance
}

// RegisterEventListener - Registers a new event listener for the specified channel UUID(or EventListenAll). Returns the registered listener ID used to remove it.
func (c *Conn) RegisterEventListener(channelUUID string, listener EventListener) string {
c.eventListenerLock.Lock()
defer c.eventListenerLock.Unlock()
@@ -82,6 +105,7 @@ func (c *Conn) RegisterEventListener(channelUUID string, listener EventListener)
return id
}

// RemoveEventListener - Removes the listener for the specified channel UUID with the listener ID returned from RegisterEventListener
func (c *Conn) RemoveEventListener(channelUUID string, id string) {
c.eventListenerLock.Lock()
defer c.eventListenerLock.Unlock()
@@ -91,6 +115,7 @@ func (c *Conn) RemoveEventListener(channelUUID string, id string) {
}
}

// SendCommand - Sends the specified ESL command to FreeSWITCH with the provided context. Returns the response data and any errors encountered.
func (c *Conn) SendCommand(ctx context.Context, command command.Command) (*RawResponse, error) {
c.writeLock.Lock()
defer c.writeLock.Unlock()
@@ -124,16 +149,18 @@ func (c *Conn) SendCommand(ctx context.Context, command command.Command) (*RawRe
}
}

// ExitAndClose - Attempt to gracefully send FreeSWITCH "exit" over the ESL connection before closing our connection and stopping. Protected by a sync.Once
func (c *Conn) ExitAndClose() {
c.closeOnce.Do(func() {
// Attempt a graceful closing of the connection with FreeSWITCH
ctx, cancel := context.WithTimeout(c.runningContext, time.Second)
ctx, cancel := context.WithTimeout(c.runningContext, c.exitTimeout)
_, _ = c.SendCommand(ctx, command.Exit{})
cancel()
c.close()
})
}

// Close - Close our connection to FreeSWITCH without sending "exit". Protected by a sync.Once
func (c *Conn) Close() {
c.closeOnce.Do(c.close)
}
@@ -228,7 +255,7 @@ func (c *Conn) eventLoop() {
c.responseChanMutex.RUnlock()

if err != nil {
log.Printf("Error parsing event\n%s\n", err.Error())
c.logger.Warn("Error parsing event\n%s\n", err.Error())
continue
}

@@ -240,7 +267,7 @@ func (c *Conn) receiveLoop() {
for c.runningContext.Err() == nil {
err := c.doMessage()
if err != nil {
log.Println("Error receiving message", err)
c.logger.Warn("Error receiving message: %s\n", err.Error())
break
}
}
@@ -273,7 +300,7 @@ func (c *Conn) doMessage() error {
return c.runningContext.Err()
case <-ctx.Done():
// Do not return an error since this is not fatal but log since it could be a indication of problems
log.Printf("No one to handle response\nIs the connection overloaded or stopping?\n%v\n\n", response)
c.logger.Warn("No one to handle response\nIs the connection overloaded or stopping?\n%v\n\n", response)
}
} else {
return errors.New("no response channel for Content-Type: " + response.GetHeader("Content-Type"))
2 changes: 1 addition & 1 deletion connection_test.go
Original file line number Diff line number Diff line change
@@ -23,7 +23,7 @@ import (

func TestConn_SendCommand(t *testing.T) {
server, client := net.Pipe()
connection := newConnection(client, false)
connection := newConnection(client, false, DefaultOptions)
defer connection.Close()
defer server.Close()
defer client.Close()
2 changes: 1 addition & 1 deletion event_test.go
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ const TestEventToSend = "Content-Length: 483\r\nContent-Type: text/event-plain\r

func TestEvent_readPlainEvent(t *testing.T) {
server, client := net.Pipe()
connection := newConnection(client, false)
connection := newConnection(client, false, DefaultOptions)
defer connection.Close()
defer server.Close()
defer client.Close()
56 changes: 43 additions & 13 deletions inbound.go
Original file line number Diff line number Diff line change
@@ -14,34 +14,62 @@ import (
"context"
"fmt"
"github.com/percipia/eslgo/command"
"log"
"net"
"time"
)

// InboundOptions - Used to dial a new inbound ESL connection to FreeSWITCH
type InboundOptions struct {
Options // Generic common options to both Inbound and Outbound Conn
Network string // The network type to use, should always be tcp, tcp4, tcp6.
Password string // The password used to authenticate with FreeSWITCH. Usually ClueCon
OnDisconnect func() // An optional function to be called with the inbound connection gets disconnected
AuthTimeout time.Duration // How long to wait for authentication to complete
}

// DefaultOutboundOptions - The default options used for creating the inbound connection
var DefaultInboundOptions = InboundOptions{
Options: DefaultOptions,
Network: "tcp",
Password: "ClueCon",
AuthTimeout: 5 * time.Second,
}

// Dial - Connects to FreeSWITCH ESL at the provided address and authenticates with the provided password. onDisconnect is called when the connection is closed either by us, FreeSWITCH, or network error
func Dial(address, password string, onDisconnect func()) (*Conn, error) {
c, err := net.Dial("tcp", address)
opts := DefaultInboundOptions
opts.Password = password
opts.OnDisconnect = onDisconnect
return opts.Dial(address)
}

// Dial - Connects to FreeSWITCH ESL on the address with the provided options. Returns the connection and any errors encountered
func (opts InboundOptions) Dial(address string) (*Conn, error) {
c, err := net.Dial(opts.Network, address)
if err != nil {
return nil, err
}
connection := newConnection(c, false)
connection := newConnection(c, false, opts.Options)

// First auth
<-connection.responseChannels[TypeAuthRequest]
err = connection.doAuth(connection.runningContext, command.Auth{Password: password})
authCtx, cancel := context.WithTimeout(connection.runningContext, opts.AuthTimeout)
err = connection.doAuth(authCtx, command.Auth{Password: opts.Password})
cancel()
if err != nil {
// Try to gracefully disconnect, we have the wrong password.
connection.ExitAndClose()
if onDisconnect != nil {
go onDisconnect()
if opts.OnDisconnect != nil {
go opts.OnDisconnect()
}
return nil, err
} else {
log.Printf("Sucessfully authenticated %s\n", connection.conn.RemoteAddr())
connection.logger.Info("Successfully authenticated %s\n", connection.conn.RemoteAddr())
}

// Inbound only handlers
go connection.authLoop(command.Auth{Password: password})
go connection.disconnectLoop(onDisconnect)
go connection.authLoop(command.Auth{Password: opts.Password}, opts.AuthTimeout)
go connection.disconnectLoop(opts.OnDisconnect)

return connection, nil
}
@@ -59,18 +87,20 @@ func (c *Conn) disconnectLoop(onDisconnect func()) {
}
}

func (c *Conn) authLoop(auth command.Auth) {
func (c *Conn) authLoop(auth command.Auth, authTimeout time.Duration) {
for {
select {
case <-c.responseChannels[TypeAuthRequest]:
err := c.doAuth(c.runningContext, auth)
authCtx, cancel := context.WithTimeout(c.runningContext, authTimeout)
err := c.doAuth(authCtx, auth)
cancel()
if err != nil {
log.Printf("Failed to auth %e\n", err)
c.logger.Warn("Failed to auth %e\n", err)
// Close the connection, we have the wrong password
c.ExitAndClose()
return
} else {
log.Printf("Sucessfully authenticated %s\n", c.conn.RemoteAddr())
c.logger.Info("Successfully authenticated %s\n", c.conn.RemoteAddr())
}
case <-c.runningContext.Done():
return
37 changes: 37 additions & 0 deletions logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package eslgo

import (
"log"
)

type Logger interface {
Debug(format string, args ...interface{})
Info(format string, args ...interface{})
Warn(format string, args ...interface{})
Error(format string, args ...interface{})
}

type NilLogger struct{}
type NormalLogger struct{}

func (l NormalLogger) Debug(format string, args ...interface{}) {
log.Print("DEBUG: ")
log.Printf(format, args...)
}
func (l NormalLogger) Info(format string, args ...interface{}) {
log.Print("INFO: ")
log.Printf(format, args...)
}
func (l NormalLogger) Warn(format string, args ...interface{}) {
log.Print("WARN: ")
log.Printf(format, args...)
}
func (l NormalLogger) Error(format string, args ...interface{}) {
log.Print("ERROR: ")
log.Printf(format, args...)
}

func (l NilLogger) Debug(string, ...interface{}) {}
func (l NilLogger) Info(string, ...interface{}) {}
func (l NilLogger) Warn(string, ...interface{}) {}
func (l NilLogger) Error(string, ...interface{}) {}
56 changes: 39 additions & 17 deletions outbound.go
Original file line number Diff line number Diff line change
@@ -14,45 +14,71 @@ import (
"context"
"errors"
"github.com/percipia/eslgo/command"
"log"
"net"
"time"
)

type OutboundHandler func(ctx context.Context, conn *Conn, connectResponse *RawResponse)

// OutboundOptions - Used to open a new listener for outbound ESL connections from FreeSWITCH
type OutboundOptions struct {
Options // Generic common options to both Inbound and Outbound Conn
Network string // The network type to listen on, should be tcp, tcp4, or tcp6
ConnectTimeout time.Duration // How long should we wait for FreeSWITCH to respond to our "connect" command. 5 seconds is a sane default.
ConnectionDelay time.Duration // How long should we wait after connection to start sending commands. 25ms is the recommended default otherwise we can close the connection before FreeSWITCH finishes starting it on their end. https://github.com/signalwire/freeswitch/pull/636
}

// DefaultOutboundOptions - The default options used for creating the outbound connection
var DefaultOutboundOptions = OutboundOptions{
Options: DefaultOptions,
Network: "tcp",
ConnectTimeout: 5 * time.Second,
ConnectionDelay: 25 * time.Millisecond,
}

/*
* TODO: Review if we should have a rate limiting facility to prevent DoS attacks
* For our use it should be fine since we only want to listen on localhost
*/
// ListenAndServe - Open a new listener for outbound ESL connections from FreeSWITCH on the specified address with the provided connection handler
func ListenAndServe(address string, handler OutboundHandler) error {
listener, err := net.Listen("tcp", address)
return DefaultOutboundOptions.ListenAndServe(address, handler)
}

// ListenAndServe - Open a new listener for outbound ESL connections from FreeSWITCH with provided options and handle them with the specified handler
func (opts OutboundOptions) ListenAndServe(address string, handler OutboundHandler) error {
listener, err := net.Listen(opts.Network, address)
if err != nil {
return err
}
log.Printf("Listenting for new ESL connections on %s\n", listener.Addr().String())
if opts.Logger != nil {
opts.Logger.Info("Listening for new ESL connections on %s\n", listener.Addr().String())
}
for {
c, err := listener.Accept()
if err != nil {
break
}
conn := newConnection(c, true, opts.Options)

log.Printf("New outbound connection from %s\n", c.RemoteAddr().String())
conn := newConnection(c, true)
conn.logger.Info("New outbound connection from %s\n", c.RemoteAddr().String())
go conn.dummyLoop()
// Does not call the handler directly to ensure closing cleanly
go conn.outboundHandle(handler)
go conn.outboundHandle(handler, opts.ConnectionDelay, opts.ConnectTimeout)
}

if opts.Logger != nil {
opts.Logger.Info("Outbound server shutting down")
}
log.Println("Outbound server shutting down")
return errors.New("connection closed")
}

func (c *Conn) outboundHandle(handler OutboundHandler) {
ctx, cancel := context.WithTimeout(c.runningContext, 5*time.Second)
func (c *Conn) outboundHandle(handler OutboundHandler, connectionDelay, connectTimeout time.Duration) {
ctx, cancel := context.WithTimeout(c.runningContext, connectTimeout)
response, err := c.SendCommand(ctx, command.Connect{})
cancel()
if err != nil {
log.Printf("Error connecting to %s error %s", c.conn.RemoteAddr().String(), err.Error())
c.logger.Warn("Error connecting to %s error %s", c.conn.RemoteAddr().String(), err.Error())
// Try closing cleanly first
c.Close() // Not ExitAndClose since this error connection is most likely from communication failure
return
@@ -61,22 +87,18 @@ func (c *Conn) outboundHandle(handler OutboundHandler) {
// XXX This is ugly, the issue with short lived async sockets on our end is if they complete too fast we can actually
// close the connection before FreeSWITCH is in a state to close the connection on their end. 25ms is an magic value
// found by testing to have no failures on my test system. I started at 1 second and reduced as far as I could go.
// TODO We should open a bug report on the FreeSWITCH GitHub at some point and remove this when fixed.
// TODO This actually may be fixed: https://github.com/signalwire/freeswitch/pull/636
time.Sleep(25 * time.Millisecond)
ctx, cancel = context.WithTimeout(c.runningContext, 5*time.Second)
_, _ = c.SendCommand(ctx, command.Exit{})
cancel()
time.Sleep(connectionDelay)
c.ExitAndClose()
}

func (c *Conn) dummyLoop() {
select {
case <-c.responseChannels[TypeDisconnect]:
log.Println("Disconnect outbound connection", c.conn.RemoteAddr())
c.logger.Info("Disconnect outbound connection", c.conn.RemoteAddr())
c.Close()
case <-c.responseChannels[TypeAuthRequest]:
log.Println("Ignoring auth request on outbound connection", c.conn.RemoteAddr())
c.logger.Debug("Ignoring auth request on outbound connection", c.conn.RemoteAddr())
case <-c.runningContext.Done():
return
}
1 change: 1 addition & 0 deletions utils.go
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ import (
"strings"
)

// BuildVars - A helper that builds channel variable strings to be included in various commands to FreeSWITCH
func BuildVars(format string, vars map[string]string) string {
// No vars do not format
if vars == nil || len(vars) == 0 {

0 comments on commit 8a4ed26

Please sign in to comment.