diff --git a/README.md b/README.md index 21b9355..d7bd456 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/command/command.go b/command/command.go index 7b0eaaf..a5e9b95 100644 --- a/command/command.go +++ b/command/command.go @@ -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 } diff --git a/connection.go b/connection.go index dcd4d8f..46d0072 100644 --- a/connection.go +++ b/connection.go @@ -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")) diff --git a/connection_test.go b/connection_test.go index e87c04b..ed63340 100644 --- a/connection_test.go +++ b/connection_test.go @@ -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() diff --git a/event_test.go b/event_test.go index 8184806..6c18837 100644 --- a/event_test.go +++ b/event_test.go @@ -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() diff --git a/inbound.go b/inbound.go index 55fcd0a..5f05f53 100644 --- a/inbound.go +++ b/inbound.go @@ -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 diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..682c171 --- /dev/null +++ b/logger.go @@ -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{}) {} diff --git a/outbound.go b/outbound.go index 16eacdd..3f0f222 100644 --- a/outbound.go +++ b/outbound.go @@ -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 } diff --git a/utils.go b/utils.go index c94998e..d07412f 100644 --- a/utils.go +++ b/utils.go @@ -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 {