freeswitch-esl

freeswitch-esl is a Ruby gem for interacting with FreeSWITCH through ESL (Event Socket Library). This version is intentionally focused on one direction only: your Ruby process connects inbound to mod_event_socket and manages one or more Freeswitch::ESL::Client instances.

The implementation keeps the public API small and groups protocol concerns separately:

  • Freeswitch::ESL::Client
  • Freeswitch::ESL::Connection
  • Freeswitch::ESL::Protocol::Message
  • Freeswitch::ESL::Protocol::Event

Why ESL

For FreeSWITCH, ESL is still the most complete and stable integration surface for external control. Other interfaces exist, but they are generally less complete:

  • mod_xml_rpc: useful for some management operations, but not a replacement for event streaming and call control
  • custom REST/gRPC wrappers: possible, but usually built on top of ESL rather than replacing it

If you need event subscriptions, command execution and background jobs, ESL remains the right interface.

Installation

Choose one installation mode based on your environment.

Local path (development)

Use this while developing the gem and application together:

gem 'freeswitch-esl', path: '/path/to/freeswitch-esl'

Git source (production)

Use this when you want to pin a tag or commit from your repository:

gem 'freeswitch-esl', git: 'https://gitlab.example.com/your-group/freeswitch-esl.git', tag: 'v0.1.0'

RubyGems style (for future releases)

When the gem is published on RubyGems, the classic entry is:

gem 'freeswitch-esl'

Then require it:

require 'freeswitch-esl'

Configuration

Configure the library once and then connect a client:

require 'logger'
require 'freeswitch-esl'

Freeswitch::ESL.configure do |config|
  config.freeswitch.host = 'freeswitch.example.com'
  config.freeswitch.port = 8021
  config.freeswitch.password = 'ClueCon'
  config.freeswitch.reconnect = true
  config.freeswitch.retry_delay = 1.0
  config.freeswitch.max_retries = 5
  config.logger = Logger.new($stdout)
  config.logger.level = Logger::INFO
  config.debug = false
end

Defaults:

  • config.freeswitch.host = '127.0.0.1'
  • config.freeswitch.port = 8021
  • config.freeswitch.password = nil
  • config.freeswitch.reconnect = true
  • config.freeswitch.retry_delay = 1.0
  • config.freeswitch.max_retries = Float::INFINITY
  • config.logger = Logger.new(IO::NULL)
  • config.debug = false

FreeSWITCH Configuration

Inbound ESL (mod_event_socket)

FreeSWITCH must expose mod_event_socket. For local development, a minimal configuration looks like this:

<configuration name="event_socket.conf" description="Socket Client">
  <settings>
    <param name="listen-ip" value="0.0.0.0"/>
    <param name="listen-port" value="8021"/>
    <param name="password" value="ClueCon"/>
    <param name="apply-inbound-acl" value="lan"/>
  </settings>
</configuration>

For production, tighten the ACL further and do not expose port 8021 publicly. In the included Docker setup, lan is intentional because it allows both loopback and Docker private-network traffic while still avoiding a fully open event socket.

Usage

Connect a client

require 'freeswitch-esl'

Freeswitch::ESL.configure do |config|
  config.freeswitch.host = '127.0.0.1'
  config.freeswitch.port = 8021
  config.freeswitch.password = 'ClueCon'
end

client = Freeswitch::ESL::Client.connect

client.subscribe('CHANNEL_CREATE', 'CHANNEL_HANGUP', 'DTMF')
client.on('CHANNEL_CREATE') do |event|
  puts "new channel: #{event.uuid}"
end

client.on('DTMF') do |event|
  puts "digit=#{event['DTMF-Digit']} duration=#{event['DTMF-Duration']}"
end

response = client.exec('status').response
puts response.body

client.close

Freeswitch::ESL::Client.connect builds a client with the global configuration, opens the TCP socket, waits for FreeSWITCH auth/request, authenticates, subscribes to the default event set, and returns a ready client instance.

Reconfigure an existing client

client = Freeswitch::ESL::Client.connect

client.close
client.configure(freeswitch: { host: 'fs-backup.example.com', reconnect: true })
client.connect

Background API

require 'freeswitch-esl'

client = Freeswitch::ESL::Client.connect

command = client.exec('originate', 'sofia/default/1000 &park')

command.on_complete do |completed|
  puts "job #{completed.job_uuid} finished with status=#{completed.status}"
  puts completed.response.body if completed.response
end

puts "submitted job=#{command.job_uuid}"

command.wait
client.close

Client#exec wraps bgapi and returns a Freeswitch::ESL::Command object. You can:

  • call command.wait for blocking flow
  • call command.response to block until completion and get the final BACKGROUND_JOB event
  • inspect command.status, command.ok?, command.failed?, command.timeout?
  • attach command.on_complete { |cmd| ... } for async completion handling

If you prefer lower-level primitives, Freeswitch::ESL::Client also exposes the inherited connection API:

  • send_command
  • bgapi
  • subscribe
  • unsubscribe
  • filter
  • on

Auto reconnect

When config.freeswitch.reconnect is enabled, Client#connect retries on initial connection/authentication failure and the client reconnects automatically after later disconnects.

Event handlers remain registered across reconnects because the event dispatcher is preserved. Subscriptions do not: after reconnect, re-subscribe to the events you need.

require 'freeswitch-esl'

client = Freeswitch::ESL::Client.connect

client.on('CHANNEL_HANGUP') do |event|
  puts "hangup cause=#{event['Hangup-Cause']}"
end

client.on_reconnect do
  client.subscribe('CHANNEL_HANGUP')
end

client.subscribe('CHANNEL_HANGUP')

client.close stops reconnect attempts immediately. The same client instance can be connected again later with client.connect.

Docker Compose Test Lab

This repository includes a local FreeSWITCH environment for ESL experimentation. It is intended for development and manual testing, and it can later serve as a base for realistic integration tests.

Files included:

  • docker-compose.yml
  • docker/freeswitch/autoload_configs/event_socket.conf.xml
  • examples/inbound_status.rb

The compose file builds FreeSWITCH locally from the official source repository release. The Dockerfile is aligned with the upstream reference at signalwire/freeswitch/docker/examples/Debian11/Dockerfile, adapted for Debian Bookworm and this project's runtime needs. By default it uses FS_VERSION=latest.

To pin a specific release tag (recommended for reproducible environments), set the build arg in docker-compose.yml:

services:
  freeswitch:
    build:
      args:
        FS_VERSION: v1.10.12

Start FreeSWITCH

docker compose up -d

If you change image or base distro later, verify that the configuration directory is still /usr/local/freeswitch/etc/freeswitch.

Check that FreeSWITCH is up

docker compose exec freeswitch fs_cli -x 'status'

Test inbound ESL connectivity

require 'freeswitch-esl'

Freeswitch::ESL.configure do |config|
  config.freeswitch.host = '127.0.0.1'
  config.freeswitch.port = 8021
  config.freeswitch.password = 'ClueCon'
end

client = Freeswitch::ESL::Client.connect
puts client.exec('status').response.body
client.close

Or run the ready-made example:

ruby examples/inbound_status.rb

Production Notes

  • keep ESL bound to a private interface whenever possible
  • protect the event socket with ACLs and a non-default password
  • do not use the included Docker configuration as-is in production
  • prefer explicit event subscriptions instead of ALL unless you really need global event traffic

Development

Run style checks:

bundle exec rubocop

Run tests:

bundle exec rspec

GitLab CI local

Run the full local pipeline:

gitlab-ci-local

Run only the test job:

gitlab-ci-local rspec

Run only lint:

gitlab-ci-local rubocop

Test stability note

Some client specs use reconnect threads. To avoid order-dependent failures in random runs, the test teardown always closes created clients. This makes sure reconnect-related activity is stopped before the next example starts.