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:
- Setting up a server socket
- Accepting client connections
- Doing something with the connected client
- Sending back an answer
- 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:
- 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.
- Calling
getshere does not have a timeout. You can useread_nonblockand some code around that instead, or perhaps handle it with threads. - Maybe less of a thing to remember, but Ruby and the
TCPServerclass 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:
- Address family
- Port
- IP or hostname if reverse DNS is used
- IP
- Protocol family (long story short, think of it as address family)
- Socket type (STREAM/DGRAM/RAW/…)
- 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:
- IPv6+Ruby part 1: Introduction to IPv6
- IPv6+Ruby part 2: IPv6 and web applications
- IPv6+Ruby part 3: Dual stack (IPv4+IPv6) TCP sockets
- IPv6+Ruby part 4: Dual stack (IPv4+IPv6) TCP sockets
- IPv6+Ruby part 5: Service discovery with IPv6 and Multicast
References
- https://ruby-doc.org/stdlib-2.5.3/libdoc/socket/rdoc/TCPServer.html
- https://ruby-doc.org/stdlib-2.5.3/libdoc/socket/rdoc/Addrinfo.html
- https://ruby-doc.org/stdlib-2.5.3/libdoc/socket/rdoc/Socket.html
- https://ruby-doc.org/stdlib-2.5.3/libdoc/ipaddr/rdoc/IPAddr.html
- https://ruby-doc.org/stdlib-2.5.3/libdoc/socket/rdoc/UDPSocket.html