IPv6+Ruby part 3: Dual stack (IPv4+IPv6) TCP sockets

Table of Contents

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

Ok, so you know what IPv6 is and you know why you need to support it. This post will give examples on how to use IPv6 using a TCP socket. I will use Ruby for the examples here, but most of the basics and concepts are the same in other languages.

Since we will be using TCP for these examples, let us go over the basics of the transport protocols.

TCP and UDP

The two most used transport protocols today are Transmission Control Protocol (TCP) and the User Datagram Protocol (UDP).

TCP re-transmits on errors, delivers your data in order, and establishes an active connection between end points. So if you send data X, Y, Z in that order, it will be presented to the other end of the connection as X, Y, Z . If some packet is lost during transmission TCP will re-transmit the lost packet to make sure it gets there.

UDP does not have built reliability or ordering, but also does not require setting up a connection between the server and client. This means that the data will in all likelihood get delivered, but in case it does not, UDP does nothing to re-transmit lost data. With no sequence numbers in UDP, if you are sending data X, Y, Z in that order, it might arrive as Z, X, Y and be presented as such, but for some applications that does not really matter. UDP also does not suffer from Head-of-line blocking.

A rule of thumb for choosing whether to use TCP or UDP is:

  • TCP — You need your data to arrive, but it is not important when. This could be a file you are downloading or a HTTP request you are sending to a webserver. If something in the transfer goes wrong, you definitely want it corrected by the network instead of having to throw out your download or request and make them all over again. TCP uses handshakes to set up the connection and will be slower to get the first data to the destination.
  • UDP — Your data is only relevant right now. For example, it could be a live TV broadcast or a Skype call. In case something got lost, we would not gain anything from getting those 10 video frames 2 seconds later or getting the audio from 5 seconds ago.
  • Side note: These lines are getting a bit blurred for some newer protocols like Google’s QUIC protocol. This protocol is the basis for HTTP/3 and uses UDP, but then has all the re-transmission and re-ordering in the QUIC protocol. The main point is that a lot of people do not like TCP for speedy applications, but also know that we do not have a widespread alternative with re-transmission available yet.

One socket, two address families

Let us say that you have an application that already supports IPv4 and you now need to support IPv6. This application has some sort of server component that is currently only using IPv4.

So what to do? Most operating systems* support what is called “Dual-stack sockets”. These sockets will listen on not only IPv4 or IPv6, but both at the same time. That means that when you before perhaps took connections on "0.0.0.0" or often simply just "", you will now instead use "::".

  • OpenBSD does not support Dual-stack sockets, but is the only OS I know of that does not support them.

If you are using Rails and want to test your application locally, you can do this by running Rails as:

rails s -b [::]

You can find interesting bugs if you have been using IP addresses in your application. It can also just work. Other frameworks like Sinatra or Flask are similar, look for the “Bind” option and bind to [::]. Using the Ruby TCPServer class, this would be:

server = TCPServer.new("::", port_to_listen_on)

Now you might be thinking: “If the server is listening on IPv6, how does that impact the IPv4 connections?”. Here, the OS will do you a favour and present IPv4 connections to the application as IPv4 mapped IPv6. IPv4 mapped IPv6 is the IPv4 address embedded in the IPv6 address (kind of like NAT64 that was described in the previous post) with a prefix of: ::ffff:0:0/96. If 140.82.118.3 connects, the connection will most often be shown as ::ffff:140.82.118.3 which in reality means ::ffff:8c52:7603 when representing the lower 32-bits in HEX.

The best part about this is that even if your application is running on an endpoint without IPv6, you can still use Dual-stack sockets, enabling you to use only one socket implementation no matter where you deploy.

Making IPv6 sockets

Putting all this together, we can make a simple echo server and client for both IPv4 and IPv6. Let us stick with TCP for now, and look at a UDP and a UDP multicast version in the next posts.

Server

To make this simple server a few things are needed:

  1. Setting up a server socket
  2. Accepting client connections
  3. Doing something with the connected client
  4. Sending back an answer
  5. Closing the client socket

The first part is setting up the server socket. We need to require socket and create the server socket. Ruby is really nice here and offers the TCPServer class. In the most bare-bones version, two arguments are needed: a listen address and a listen port. The port of 12345 is the port to listen on. This ends up as:

require "socket"

LISTEN_ADDR = "::"
LISTEN_PORT = 12345

server_socket = TCPServer.new(LISTEN_ADDR, LISTEN_PORT)

Now our server_socket is created but it is not doing anything yet. We can accept client connections with the TcpSocket#accept instance method:

client_socket = server_socket.accept

This call will block until a client connects, and will return the client socket upon connection.

A thing we can do with the connected client is check whether the client is connected via IPv4 or IPv6, and log that. We do that by getting the AddrInfo object that is accessible via the #connected_address method, and ask that object if this is IPv4 mapped IPv6 with the slightly confusingly named method #ipv6_v4mapped?:

addr_info = client_socket.connect_address
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 address family, as well as the original client message, and close the client socket:

client_socket.puts "[#{addr_info.ipv6_v4mapped? ? "IPv4" : "IPv6"}]" +
  " #{client_socket.gets.chomp}"
client_socket.close

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

require "socket"

LISTEN_ADDR = "::"
LISTEN_PORT = 12345

server_socket = TCPServer.new(LISTEN_ADDR, LISTEN_PORT)

loop do
  # Accept client TCP connection
  client_socket = server_socket.accept

  # Get the AddrInfo object and log connection
  addr_info = client_socket.connect_address
  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
  client_socket.puts "[#{addr_info.ipv6_v4mapped? ? "IPv4" : "IPv6"}]" +
    " #{client_socket.gets.chomp}"
  client_socket.close
end

We can quickly test our server by starting it and connecting to it using Netcat or nc. First, we run the server:

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

Then use nc with our host localhost and port 12345 as arguments:

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

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

Great, this works as expected! (And yes, I named my computers after Star Wars characters).

Things to consider here if running in a real world 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 gets here does not have a timeout. You can use read_nonblock and some code around that instead, or perhaps handle it with threads.
  3. Maybe less of a thing to remember, but Ruby and the TCPServer class does a lot of setup in the background for the sockets. Remember to appreciate this.

Ok, time to make a client!

Client

Normally, 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. This would be done like:

require 'socket'
CONNECT_ADDR = "localhost"
CONNECT_PORT = 12345

socket = TCPSocket.new(CONNECT_ADDR, CONNECT_PORT)
puts "Connected to #{socket.remote_address.ip_address}"
socket.puts "Connected via unspecified address family socket"
puts socket.gets
socket.close

This works fine and returns:

cmol@qui-gon:~$ ruby src/echo_client.rb
Connected to ::1
[IPv6] Connected via unspecified address family socket

But we can also inspect our possibilities a bit more and choose where to connect for the purpose of digging a bit deeper.

So first of all, Socket has a method Socket#getaddrinfo. This method can tell us how to connect to a certain host.

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

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

Each entry 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:

Socket.getaddrinfo(CONNECT_ADDR,CONNECT_PORT, :AF_UNSPEC, :STREAM).each do | con |
  af, port, hostname, ip, pf, sock_type, ipproto = con
  socket = TCPSocket.new(ip, port)
  puts "Connected to #{socket.remote_address.ip_address}"
  socket.puts "Connected via #{address_family}"
  puts socket.gets
  socket.close
end

Running this gives us:

cmol@qui-gon:~$ ruby src/echo_client.rb
Connected to ::1
[IPv6] Connected via AF_INET6
Connected to 127.0.0.1
[IPv4] Connected via AF_INET

This means that we can connect to our local server via IPv4 and IPv6, but given the unspecified socket version, our system prefers IPv6 over IPv4. That is a good thing!

Wrapping up

In this article, we managed to successfully create a TCP echo (ish) server and client using IPv6. The changes that need to be made to applications can be fairly small in a lot of cases where there is not any heavy lifting network logic.

This article is part 3 in a series consisting of:

References