Thursday, November 01, 2012

Multicast in Ruby: Building a Peer-to-Peer Chat System

IP multicasting allows a node to send one datagram to multiple interested receivers. Hosts indicate their interest in traffic by subscribing to a multicast address. Datagrams sent to this multicast address will be received by all member nodes on a local network. A multicast address is any host address in the 224/8 - 239/8 range of addresses which is reserved for multicast.

Services that use multicasting are not often found on the public internet due to the complexities involved in sharing this subscription state between neighboring external networks and the lack of incentive for ISPs to support it. You probably don't use multicast directly day-to-day, but if you're using a OS X or Linux system it's likely to be a member of a couple of multicast groups by default.

jp@oeuf:~$ netstat -g
...
IPv4 Multicast Group Memberships

Group               Link-layer Address  Netif
224.0.0.1           1:0:5e:0:0:1        en0
224.0.0.251         1:0:5e:0:0:fb       en0

224.0.0.1 is the All Hosts multicast group. RFC1122 dictates that all hosts that fully support multicasting must always maintain a membership for it. 224.0.0.251 is the mDNS multicast group which OS X uses for DNS resolution of the .local domain.

If we send an ICMP echo request to either of these addresses, we'll get back an ICMP echo reply for each member host:

jp@oeuf:~$ ping 224.0.0.251
PING 224.0.0.251 (224.0.0.251): 56 data bytes
64 bytes from 192.168.1.5: icmp_seq=0 ttl=64 time=71.531 ms
64 bytes from 192.168.1.6: icmp_seq=0 ttl=64 time=75.006 ms

Using tcpdump we can see that while we only send one request we get two replies with the same sequence number:

jp@oeuf:~$ sudo tcpdump -i en0 icmp
20:46:43.659398 IP oeuf.home > 224.0.0.251: ICMP echo request, id 37572, seq 0, length 64
20:46:43.744414 IP ipad.home > oeuf.home: ICMP echo reply, id 37572, seq 0, length 64
20:46:43.744425 IP apple-tv.home > oeuf.home: ICMP echo reply, id 37572, seq 0, length 64

Multicasting in Ruby

Ruby's socket library exposes a wrapper to the underlying operating system socket implementation. Normally we'd be working with abstractions well above socket. It's pretty low-level and isn't the friendliest library to work with, but it allows us to directly manipulate sockets directly to properly bind to the multicast address group.

Here's a basic send/receive example. The first script, send.rb, opens up a UDP socket, sets the multicast TTL of the datagram to 1 to prevent it from being forwarded beyond our local network, and sends whatever the first command line argument passed to the script was across the socket.

require "socket"

MULTICAST_ADDR = "224.0.0.1"
PORT = 3000

socket = UDPSocket.open
socket.setsockopt(:IPPROTO_IP, :IP_MULTICAST_TTL, 1)
socket.send(ARGV[0], 0, MULTICAST_ADDR, PORT)
socket.close

receive.rb also opens a UDP socket but does a little more work to set itself up to receive messages from the multicast address group. It sets two options on the socket: one to add the membership to the IP multicast group and one to allow multiple receivers to bind to the same port. The second option allows two or more programs on the same host to receive messages from the same multicast group. Lastly, it binds to the address and port and then sets up a small loop to block, wait for a message, and print its contents to the terminal.

require "socket"
require "ipaddr"

MULTICAST_ADDR = "224.0.0.1"
BIND_ADDR = "0.0.0.0"
PORT = 3000

socket = UDPSocket.new
membership = IPAddr.new(MULTICAST_ADDR).hton + IPAddr.new(BIND_ADDR).hton

socket.setsockopt(:IPPROTO_IP, :IP_ADD_MEMBERSHIP, membership)
socket.setsockopt(:SOL_SOCKET, :SO_REUSEPORT, 1)

socket.bind(BIND_ADDR, PORT)

loop do
  message, _ = socket.recvfrom(255)
  puts message
end

The socket library is not the easiest to work with and usually involves a lot of man page reading. Previous editions of the pickaxe have a whole appendix for the socket library but pragprog decided to remove it from the book in the latest update. Luckily, they have released its contents for free in PDF and e-reader formats.

Chat, Serverlessly

Articles about building a chat server in a given toolset are a trope of programming writing. Let's embrace the cliche and take that example but implement it in as a peer to peer service using multicast to allow chat clients on different hosts on the same network to exchange messages.

We'll call the project backchannel. Its basic operations are:

  1. When the client receives a message through the socket from another user, draw the message into the window

  2. When the user types in a message and hits return, send that message to other listening clients over the socket

Clients will become a member of a multicast group and use the group to exchange chat messages. I used ruby to pick a random number (rand(10_000)) and drew 6188 so I'll use 224.6.1.88 as the multicast address and bind to port 6188.

In our description we've mentioned three different nouns: a client, a window and a message. Let's start by doing some cocktail napkin design.

Client is responsible for sending and receiving messages. It exposes a listener interface to allow listeners to be alerted to new messages and a transmit method for sending arbitrary content across the socket.

Window is responsible for managing the UI which entails drawing messages into the terminal and capturing our input and sending new messages. It'll require a handle onto the client to allow us to transmit messages and it'll need to keep a backlog of messages to be able to draw chat history.

Message will be transmitted as a human readable JSON objects. It will have three attributes: a client ID, the user's handle and some message content. Let's start with Message since it's a simple value object:

require "json"

class Message
  attr_reader :client_id, :handle, :content

  def self.inflate(json)
    attributes = JSON.parse(json)
    new(attributes)
  end

  def initialize(attributes={})
    @client_id = attributes.fetch("client_id")
    @handle = attributes.fetch("handle")
    @content = attributes.fetch("content")
  end

  def to_json
    { client_id: client_id, handle: handle, content: content }.to_json
  end
end

No surprises there. We define an attr_reader for the properties we're bundling together and some convenience methods for JSON serialization and deserialization.

Next we'll look at Client. It's the object that knows how to send and receive messages from the multicast address group. It exposes a method for sending messages and a hook for allowing another object to listen for new messages. Since it's the object responsible for chat operations, it will also generate and hold a random client_id and hold the user's chosen handle.

require "socket"
require "thread"
require "ipaddr"
require "securerandom"

class Client
  MULTICAST_ADDR = "224.6.8.11"
  BIND_ADDR = "0.0.0.0"
  PORT = 6811

  def initialize(handle)
    @handle    = handle
    @client_id = SecureRandom.hex(5)
    @listeners = []
  end

  def add_message_listener(listener)
    listen unless listening?
    @listeners << listener
  end

  def transmit(content)
    message = Message.new(
      "client_id" => @client_id,
      "handle"    => @handle,
      "content"   => content
    )

    socket.send(message.to_json, 0, MULTICAST_ADDR, PORT)
    message
  end

  private

  def listen
    socket.bind(BIND_ADDR, PORT)

    Thread.new do
      loop do
        attributes, _ = socket.recvfrom(1024)
        message = Message.inflate(attributes)

        unless message.client_id == @client_id
          @listeners.each { |listener| listener.new_message(message) }
        end
      end
    end

    @listening = true
  end

  def listening?
    @listening == true
  end

  def socket
    @socket ||= UDPSocket.open.tap do |socket|
      socket.setsockopt(:IPPROTO_IP, :IP_ADD_MEMBERSHIP, bind_address)
      socket.setsockopt(:IPPROTO_IP, :IP_MULTICAST_TTL, 1)
      socket.setsockopt(:SOL_SOCKET, :SO_REUSEPORT, 1)
    end
  end

  def bind_address
    IPAddr.new(MULTICAST_ADDR).hton + IPAddr.new(BIND_ADDR).hton
  end
end

Much of this code was adapted from the send.rb and receive.rb scripts above but it has some of its own characteristics worth discussing. listen spins up a new Thread. This is necessary because in order to listen for new messages we're using a blocking call. Spinning up a Thread will allow our program to do other work while waiting for new messages.

We've decoupled any interested receivers of messages from Client by adding a hook to allow interested parties to subscribe to messages through the add_message_listener method. Now our Window doesn't need to have any concrete wiring to Client but rather just has to register itself on initialization and implement a new_message method.

Window manages the UI and implements another dusty ruby wrapper – curses. I'm going to elide most of these details as those incantations are obscure and will be the subject of a future article.

require "curses"

class Window
  include Curses

  def initialize(client)
    @client = client
    @messages = []
  end

  def start
    @client.add_message_listener(self)

    loop do
      capture_input
    end
  end

  def new_message(message)
    @messages << message
    redraw
  end

  private

  def capture_input
    content = getstr

    if content.length > 0
      message = @client.transmit(content)
      new_message(message)
    end
  end

  def redraw
    draw_text_field
    draw_messages
    cursor_to_input_line
    refresh
  end
end

This class is fairly simple when most of the presentation layer cruft is set aside. On initialization a Client is passed in and a new array is initialized to store message history.

Once start is called, Window adds itself as a message listener. new_message will be called by Client when a new message is available. That method will add that message to the end of the array and call a redraw method to do the dirty UI details.

User input is captured via a loop using curses' getstr method. We pass the content to Client for transmission over the network. Client passes us back a Message which we add to the collection and redraw the screen.

Finally, we have some glue code to introduce Client and Window and start the program:

require "backchannel/client"
require "backchannel/window"
require "backchannel/message"

class Backchannel
  def self.start(handle)
    client = Client.new(handle)
    window = Window.new(client)

    window.start
  end
end

The result of these three small classes is an IRC-like program that allows any users connected over the same physical network to pass messages. Calling Backchannel.start will draw the screen and wire up the client to the multicast address group.

The full source of the final application is on GitHub and you can play with it by running gem install backchannel and starting backchannel with backchannel <HANDLE>. Since we're setting SO_REUSEPORT, multiple programs on the same system can connect to the same chat for demonstration purposes.

I've never used multicasting in a real-world application but will be keeping my eyes open for an opportunity. Since we're all carrying around computers in our pockets now, local, opt-in networks seem applicable to all kinds of things.

Thoughts, questions, or feedback? Please share! I'm @jpignata on Twitter and available via email at john@pignata.com. Thanks for reading!