IPv6 and Rust
How difficult is it to use Rust and its ecosystem to write network applications that support IPv6?
By Martin Hoffmann
Rust is a programming language that aims to unite memory safety and high performance while providing the tools to write correct code in a productive, friendly environment. It started as a research project within Mozilla in the late 2000s and reached a stable, ready-for-production status in 2015. At NLnet Labs, we chose Rust when starting two related projects for the routing security framework RPKI, the certification authority Krill and relying party software Routinator. Initially attracted by the memory safety guarantees, we learned to love the expressive type system that prevents many common mistakes at compile time and the excellent native build tooling.
But how difficult is it to use Rust and its ecosystem to write network applications that support IPv6?
While Rust’s standard library is very minimal, relying on easy-to-use dependency management to shift anything but the most fundamental functions to external libraries, it does contain the complete set of primitives necessary to build networking applications. These primitives are modelled after the Berkeley socket library: if you are familiar with network programming in C, Rust’s primitives will immediately be familiar to you.
Except, of course, Rust’s standard library makes use of the language’s type system. Instead of using simple untyped file descriptors, each kind of socket has its own type: TcpStream and UdpSocket for socket using TCP and UDP, respectively, and TcpListener for a socket listening for incoming TCP connections. Much like Berkeley sockets, these types have connect and bind methods that connect to a remote address or bind to a local address.
For these addresses, the socket types accept both the IPv4 and IPv6 address families. Again, the type system comes to the rescue.
Rust’s powerful enum type — essentially a tagged union type as a native, well-supported language construct — allows choosing between families. The type for an IP address, IpAddr, is such an enum that contains either an Ipv4Addr or an Ipv6Addr. The SocketAddr type adds a port number to such an IP address.
It is possible to create an IP address for a given IPv6 address manually (as below), but it is a bit tedious:
use std::net::{IpAddr, Ipv6Addr};
let addr = IpAddr::V6(Ipv6Addr::new(
0x2001, 0xdb8, 0, 0, 0, 0, 0,1
));
Luckily, this sort of thing is hardly ever necessary. Instead, addresses are typically read from user input and need to be converted from their string representation. This is very easy with the FromStr trait — Rust’s equivalent of an interface — which is implemented for all address types:
use std::net::IpAddr;
use std::str::FromStr;
let addr = IpAddr::from_str(“2001:db8::1”).unwrap();
Note the use of IpAddr here, instead of explicitly stating the address family. For the most part, networking applications shouldn’t care whether a user-provided address is IPv4 or IPv6. This interface provides this transparency.
Socket addresses can be created from strings, too. The implementation of FromStr expects the host and port to be separated by a colon and the IPv6 address wrapped in square brackets. However, there is a separate trait for creating socket addresses, ToSocketAddrs. This trait not only translates the string representation but can also deal with address and port number provided separately as a pair:
use std::net::{IpAddr, ToSocketAddrs};
let addrs = “[2001:db8::1]:443”.to_socket_addrs().unwrap();
let addrs = (“2001:db8::1”, 443).to_socket_addrs().unwrap();
Better yet, ToSocketAddrs can also look up hostnames via the local resolver. Because such lookups can result in more than one address, ToSocketAddrs actually returns an iterator over socket addresses. Does this mean you have to iterate over all these addresses when calling, say TcpStream::connect? No, the method even does this for you. And it even accepts anything that implements ToSocketAddrs as its address argument. So, to connect to NLnet Labs’ web server on port 443, you really only have to do this:
use std::net::TcpStream;
let sock = TcpStream::connect(“nlnetlabs.nl:443”).unwrap();
Under the hood, the implementation calls getaddrinfo from the underlying C library without specifying an address family. It will therefore produce a sequence of both IPv4 and IPv6 addresses if they are available and the stream will connect to the first one of these that works. Whether that will be IPv4 or IPv6 depends on the C library implementation and the local network.
For most applications, this is perfectly fine. However, with only the standard library, it is not possible to insist on hostname resolution for an IPv6 socket only. Luckily, the net2 crate extends the networking primitives with more options. While it is most commonly necessary for setting socket options, it also provides socket builders — TcpBuilder and UdpBuilder — , that allow setting the address family. Using these, a connection to NLnet Labs via IPv6 only is equally easy:
use net2::TcpBuilder;
let builder = TcpBuilder::new_v6().unwrap();
let sock = builder.connect(“nlnetlabs.nl:443”).unwrap();
While the resolver will still produce both IPv4 and IPv6 addresses, connecting with the former will fail on an IPv6-only socket, filtering out these addresses.
Binding to a local address for a TcpListener or UdpSocket works similarly. The bind method takes something that implements ToSocketAddrs, tries to bind to the each produced socket address until it actually succeeds:
use std::net::TcpListener;
let sock = TcpListener::bind(“[::1]:8080”).unwrap();
Here, too, it doesn’t matter whether the address provided is an IPv4 or IPv6 address, the standard library takes care of figuring out which address family to use.
Querying a socket for its local and, if connected, remote address is possible too. Each socket type has a method local_addr returning the local address the socket is bound to. TcpStream and UdpSocket additionally can return the remote address via peer_addr. Both methods return an IpAddr value, even if the socket was created for a specific address family through the net2 crate. While this value can be displayed directly, for instance for logging, it needs to be matched on if special IPv6 handling is required:
use std::net::{IpAddr, TcpStream};
let sock = TcpStream::connect(“nlnetlabs.nl:443”).unwrap();
let addr = sock.peer_addr().unwrap();
println!(“Peer address: {}”);
if let IpAddr::V6(addr) = addr {
// addr is now an Ipv6Addr …
}
Once again, the pattern is the same: If you are developing a networking application without any special requirements for addresses and address resolution, you don’t really need to pay attention to address families and the differences in addresses and hostname resolution — the standard library will do the right thing.
This is how a modern development platform should treat IPv6: as a given that is quietly taken care of. It is also an example of perhaps the most astounding quality of Rust and its ecosystem: the many carefully thought-through, balanced design decisions. After two years of developing production-ready software in Rust, we couldn’t be happier with our decision.