IPv6+Ruby part 4: Dual stack (IPv4+IPv6) UDP sockets

Table of Contents

This post is the fourth in a series about IPv6 and why it matters. If you are interested in the details, please read the first three articles. If you just want to know about how Ruby handles UDP in IPv6, this is the place for you.

Last time around we made a simple echo server in Ruby using TCP sockets that ran both IPv6 and IPv4. Now it is time to do the same with UDP.

Just a quick refresher if you forgot, UDP is a connectionless datagram protocol. That means that no one is handling re-transmission of lost information, and that messages are handled as single units or datagrams. There is also no reordering of datagrams, so we cannot be sure that the datagrams get to the receiver in the same order as we send them.

UDP Server

Let us jump straight into it!

To make this simple server, a few things are needed:

  1. Setting up a server socket
  2. Binding to an address and port
  3. Receiving messages
  4. Doing something with the connected client
  5. Sending back an answer
  6. Closing the client socket

The “new” thing here is binding to an address and a port, and in reality, that was just done for us automatically with the TCPServer class in the last post. There is however no UDPServer class, so we instead have to set it up by hand.

The first part is setting up the socket. We need to require socket and create the server socket. Since we want our service to listen on both IPv4 and IPv6, we give the address family :INET6 to our socket. We then bind to the address and port we want to listen on:

require "socket"

LISTEN_ADDR = "::"
LISTEN_PORT = 12345
MSG_LENGTH  = 256
FLAGS       = 0

# Create socket and bind it to the listen on all addresses and the given port
server_socket = UDPSocket.new :INET6
server_socket.bind(LISTEN_ADDR, LISTEN_PORT)

Now our server_socket is created but is not doing anything yet. We can receive datagrams from clients with the #recvfrom method on the server_socket, giving it the argument of how many bytes we want to receive:

message, client = server_socket.recvfrom(MSG_LENGTH)

This call will block until a client connects, and will upon client connection return the client message and an array with client information.

One thing we can do with the connected client is check whether the client connected via IPv4 or IPv6, and log that. We do that by getting the Addrinfo object. This time we have to make the object ourselves:

addr_info = Addrinfo.new(client)
puts "Client connected from #{addr_info.ip_address} using " +
  "#{addr_info.ipv6_v4mapped? ? "IPv4" : "IPv6"}"

Lastly, we can write back to the client with the IPv4/IPv6 info, as well as the client message, and move on to the next message:

server_socket.send("[#{addr_info.ipv6_v4mapped? ? "IPv4" : "IPv6"}]" +
                   " #{message.chomp}",
                   FLAGS, addr_info.ip_address, addr_info.ip_port)

Notice how we do not have a separate socket for the client, and thus do not have a client socket to close.

Putting it all together and placing the client handling parts in a loop, gives:

require "socket"

LISTEN_ADDR = "::"
LISTEN_PORT = 12345
MSG_LENGTH  = 256
FLAGS       = 0

# Create socket and bind it to the listen on all addresses and the given port
server_socket = UDPSocket.new :INET6
server_socket.bind(LISTEN_ADDR, LISTEN_PORT)

loop do
  # Listen for messages of up to specified length
  message, client = server_socket.recvfrom(MSG_LENGTH)

  # Extract client information given as array and log connection
  addr_info = Addrinfo.new(client)
  puts "Client connected from #{addr_info.ip_address} using " +
    "#{addr_info.ipv6_v4mapped? ? "IPv4" : "IPv6"}"

  # Write back to client with AddressFamily and reversed original message
  server_socket.send("[#{addr_info.ipv6_v4mapped? ? "IPv4" : "IPv6"}]" +
                     " #{message.chomp.reverse}",
                     FLAGS, addr_info.ip_address, addr_info.ip_port)
end

We can quickly test our server by starting it and connecting to it using Netcat or nc by first running the server:

cmol@qui-gon:~$ ruby src/chat_server_udp.rb
Client connected from ::ffff:127.0.0.1 using IPv4
Client connected from ::1 using IPv6

And then using nc to connect:

cmol@qui-gon:~$ nc -4 -u localhost 12345
Hello from IPv4!
[IPv4] Hello from IPv4!

cmol@qui-gon:~$ nc -6 -u localhost 12345
Hello from IPv6!
[IPv6] Hello from IPv6!

Great, this works as expected!

A few things to think about in a production environment:

  1. If you have a server handling multiple concurrent clients, you might want to create a new thread for the client handling or forking your process
  2. Calling recvfrom here does not have a timeout. You can use recvfrom_nonblock and some code around that instead, or perhaps handle it with threads if you need it non-blocking

Ok, time to make a client!

Client

Just like last time, when connecting to some endpoint as a client, the OS should really be the one deciding how to connect to that client based on whatever DNS entries are available, as well as OS preference. With UDP we can do this using Addrinfo as that will automatically create the correct socket type. This would be done like:

require "socket"

CONNECT_ADDR = "localhost"
CONNECT_PORT = 12345
MSG_LENGTH  = 256
FLAGS       = 0

Addrinfo.udp(CONNECT_ADDR, CONNECT_PORT).connect do |socket|
  socket.send("Connected via unspecified address family socket", FLAGS)
  message, server = socket.recvfrom(MSG_LENGTH)
  puts message
end

This works and returns:

mol@qui-gon:~$ ruby echo_client_udp.rb
[IPv6] Connected via unspecified address family socket

Just as in the last post, we can inspect our possibilities a bit more, and choose where to connect for the purpose of digging a bit deeper using the Socket#getaddrinfo method.

irb(main):003:0> Socket.getaddrinfo("localhost", 12345, :AF_UNSPEC, :DGRAM)
=> [
     ["AF_INET6", 12345,       "::1",       "::1", 10, 2, 17],
     ["AF_INET",  12345, "127.0.0.1", "127.0.0.1",  2, 2, 17]
   ]

Instead of 12345 you can use a string like "https" and let the system find the port itself.

As a refresher, each element in the array represents “a way to connect” with the information:

  1. Address family
  2. Port
  3. IP or hostname if reverse DNS is used
  4. IP
  5. Protocol family (long story short, think of it as address family)
  6. Socket type (STREAM/DGRAM/RAW/…)
  7. Protocol type (TCP/UDP/IP/SCTP/…)

Using this, we can try them all, just for the fun of it with:

Socket.getaddrinfo(CONNECT_ADDR,CONNECT_PORT, :AF_UNSPEC, :DGRAM).each do | con |
  af, port, hostname, ip, pf, sock_type, ipproto = con
  Addrinfo.udp(ip, port).connect do |socket|
    socket.send("Connected via #{af}", FLAGS)
    message, server = socket.recvfrom(MSG_LENGTH)
    puts message
  end
end

Putting it all together:

require "socket"

CONNECT_ADDR = "localhost"
CONNECT_PORT = 12345
MSG_LENGTH  = 256
FLAGS       = 0

Addrinfo.udp(CONNECT_ADDR, CONNECT_PORT).connect do |socket|
  socket.send("Connected via unspecified address family socket", FLAGS)
  message, server = socket.recvfrom(MSG_LENGTH)
  puts message
end

Socket.getaddrinfo(CONNECT_ADDR,CONNECT_PORT, :AF_UNSPEC, :DGRAM).each do | con |
  af, port, hostname, ip, pf, sock_type, ipproto = con
  Addrinfo.udp(ip, port).connect do |socket|
    socket.send("Connected via #{af}", FLAGS)
    message, server = socket.recvfrom(MSG_LENGTH)
    puts message
  end
end

With the output being:

cmol@qui-gon:~$ ruby echo_client_udp.rb
[IPv6] Connected via unspecified address family socket
[IPv6] Connected via AF_INET6
[IPv4] Connected via AF_INET

Wrapping up

In this article we managed to successfully create a UDP echo server and client using IPv6. The changes needed to applications can be fairly small in a lot of cases where there is not any heavy lifting network stuff.

This article is part 4 in a series consisting of:

References