Introduction
In this post we're going to explore how to create IP-in-IP tunnels without writing a userspace encapsulation driver. The main advantage here is keeping the userspace code clean and simple.
IP-in-IP tunnels
This is a type of managed tunnel. It works by simply adding an outer IP header to the datagrams. The local and remote IP addresses have to be configured beforehand. There is no encryption or handshaking. It's about as simple as it gets.
You can tunnel a version of IP inside of itself or the other version. This allows for the following combinations to be implemented:
- IPv4 in IPv4: ipip / 4in4
- IPv6 in IPv6: ip6ip6 / 6in6
- IPv6 in IPv4: sit / 6in4
- IPv4 in IPv6: ipip6 / 4in6
Inter-protocol tunnels are much more common than their intra-protocol variants. IPv6-in-IPv4 lies at the core of many IPv6 transition mechanisms including 6in4 using a tunnel broker or legacy 6to4. IPv4-in-IPv6 is a core component of DS-Lite.
These tunnels require kernel support and can be configured
using the ip tunnel
subcommand.
Support is enabled by building Linux with the following flags:
CONFIG_NET_IP_TUNNEL=y
CONFIG_INET_TUNNEL=y
CONFIG_INET6_TUNNEL=y
CONFIG_IPV6_TUNNEL=y
CONFIG_IPV6_SIT=y
CONFIG_NFT_TUNNEL=y
So how does it work?
Unlike most other types of network interfaces tunnels don't require netlink configuration. Creating links like VLANs does require this, but tunnels don't. Netlink is still used to change configuration parameters like the IP addresses but it's not involved in providing the creation and deletion API.
Let's reimplement it
This could probably be implemented using unsafe Rust code without the need for C bindings, but they do make our life much easier.
Tunnel control works using the ioctl
syscall on any dummy IPv4 or IPv6 socket.
You should probably use the outer protocol, though IPv6 might work
for everything.
The control socket is created as follows:
int fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
not including any error handling. IPPROTO_IP
is zero.
Here's the ioctl
we have to prepare to create a 4in4 tunnel:
struct ip_tunnel_parm p;
strcpy(p.name, "footnl0");
p.iph.version = 4; // outer protocol
p.iph.ihl = 5;
p.iph.ttl = 64;
p.iph.protocol = IPPROTO_IPIP; // inner protocol
p.iph.saddr = /* our address as big-endian u32 */
p.iph.daddr = /* remote address as big-endian u32 */
p.link = if_nametoindex("ppp0"); // parent interface
struct ifreq ifr;
strcpy(ifr.ifr_name, "tunl0"); // default name for our tunnel type
ifr.ifr_ifru.ifru_data = (char *) &p;
ioctl(fd, SIOCADDTUNNEL, &ifr);
This will create the tunnel assuming you have the required permissions.
The default interface name for 4in4 is tunl0
. This is different
for the other tunnel types. From what I understand sit0
is used if the inner protocol is IPv6, but if it doesn't work
you'll have to read the iproute2 source code and test it for yourself.
One easy way to do this is making a debug build and attaching gdb
to set a breakpoint at the ioctl
call and inspect the ifr
struct.
The MTU is set automatically but bringing the interface up and configuring the internal addressing is up to you. Also unlike the more complex PPP driver the tunneling code does not delete the interface when your application exits. If this is unwanted behavior you need to call a deletion function before exiting which can even be automated using Rust's destructors.
An interesting fact to note is that the MTU is automatically calculated from the overhead and the parent MTU. Because of this it's always going to be correct, even if you're tunneling through PPPoE.
Deletion
Initialise a socket like you did when creating the tunnel.
The request and ioctl
call are different though:
struct ip_tunnel_parm p;
strcpy(p.name, "footnl0");
struct ifreq ifr;
strcpy(ifr.ifr_name, "footnl0");
ifr.ifr_ifru.ifru_data = (char *) &p;
ioctl(fd, SIOCDELTUNNEL, &ifr);
Thankfully all of this is much simpler than PPP once you figure out how it works. Have fun with your cleanly created tunnels!