Networking · lesson 12

TUN devices

A VPN is just a program. Your browser never heard of it. Yet every packet the browser sends to 100.64.0.2 lands in that program's buffer, as bytes, from a plain read() call. The mechanism is a network interface with a file descriptor where the wire should be.

in progress
14 min interactive

How does a program get packets it was never sent?

Every program you have written in this track received bytes because someone addressed them to its socket. A VPN daemon breaks that rule. Your browser opens a connection to 100.64.0.2. It has no idea a VPN exists. The kernel builds the IP packet, consults the routing table, and then the packet appears in the daemon's memory. Not in a socket's receive buffer. In a buffer the daemon passed to read.

You know two things that make this possible. From the file-descriptors lesson: anything the kernel can hand you bytes from is an fd. From the routing lesson: a route can point at a virtual interface. A TUN device is where those two facts meet.

An interface with a file descriptor on the other end

To the kernel, tun0 looks like eth0. It has an IP address. It shows up in ip addr. Routes can name it. When the routing table picks it, the kernel does exactly what it does for a real interface: it finishes the IP packet and hands it to the device driver.

The difference is what the driver does next. The eth0 driver writes the frame into the network card's transmit ring. The tun driver appends the packet to a queue attached to an open file descriptor. Your next read on that fd returns the packet. The whole packet: IP header first, then whatever came after it. Byte 0 of your buffer is byte 0 of the IP header you learned to parse in lesson 01.

It works in reverse too. write one IP packet to the fd and the kernel treats it as a packet that just arrived on tun0. It parses the header, looks at the destination, and delivers it to whichever socket is bound there. The receiving program sees a packet from the network. It came from your buffer.

💡
A TUN device is a pipe between the kernel's IP stack and your process. Packets routed to tun0 come out of your read. Packets you write go into the kernel as if a wire delivered them. The kernel does the routing and the socket delivery. You decide what happens in between.

Creating one

You open /dev/net/tun like any file. That fd is not yet an interface. One ioctl turns it into one: TUNSETIFF takes an ifreq struct with the name you want and two flags. IFF_TUN means IP packets, not Ethernet frames. IFF_NO_PI tells the driver not to prepend a 4-byte "packet info" header, so byte 0 really is the IP version field.

c
#include <fcntl.h> #include <linux/if.h> #include <linux/if_tun.h> #include <string.h> #include <sys/ioctl.h> #include <unistd.h> int tun_open(const char *name) { int fd = open("/dev/net/tun", O_RDWR); if (fd == -1) { perror("open /dev/net/tun"); return -1; } struct ifreq ifr; memset(&ifr, 0, sizeof ifr); ifr.ifr_flags = IFF_TUN | IFF_NO_PI; // IP packets, no 4-byte prefix strncpy(ifr.ifr_name, name, IFNAMSIZ - 1); // "tun0" if (ioctl(fd, TUNSETIFF, &ifr) == -1) { perror("ioctl TUNSETIFF"); // EPERM: you need CAP_NET_ADMIN close(fd); return -1; } return fd; // tun0 now exists, for as long as this fd stays open }

After the ioctl returns, tun0 exists but has no address and is down. Configuring it is the same as for any interface. The route is the important line: it is what makes the kernel send 100.64.0.0/10 traffic into your fd instead of out the real NIC. On Linux, adding the address with a prefix length installs that route for you; here it is spelled out so you can see it.

sh
# run while your program holds the tun fd open sudo ip addr add 100.64.0.1/10 dev tun0 sudo ip link set tun0 up sudo ip route add 100.64.0.0/10 dev tun0 # usually already added by the line above ip route # default via 192.168.1.1 dev eth0 # 100.64.0.0/10 dev tun0 proto kernel scope link src 100.64.0.1 # 192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.7
⚠️
Creating an interface needs CAP_NET_ADMIN. Run as root during development, or grant the capability once with sudo setcap cap_net_admin+ep ./vpnd. Without it, ioctl(TUNSETIFF) fails with EPERM and your program has an ordinary fd to /dev/net/tun that will never produce a packet. The interface also disappears the moment the fd is closed, including when your process crashes. That is a feature: no leaked interfaces.

What read() hands you

One read returns one complete IP packet. Never half of one, never two. The driver preserves packet boundaries the way UDP does, so a 2000-byte buffer and a loop is all you need. Byte 0 holds version and header length. Bytes 2–3 are the total length, big-endian. Byte 9 is the protocol. Bytes 12–15 and 16–19 are the source and destination addresses. This is the header from lesson 01, and it is sitting in your char array.

c
#include <arpa/inet.h> #include <stdint.h> #include <stdio.h> int main(void) { int tunfd = tun_open("tun0"); if (tunfd == -1) return 1; unsigned char buf[2048]; for (;;) { ssize_t n = read(tunfd, buf, sizeof buf); // blocks until a packet is routed to tun0 if (n < 20) continue; // too short to be IPv4 int version = buf[0] >> 4; // high nibble if (version != 4) continue; // ignore IPv6 for now int ihl = (buf[0] & 0x0f) * 4; // header length in bytes, usually 20 uint16_t total; memcpy(&total, buf + 2, 2); int proto = buf[9]; // 1 ICMP, 6 TCP, 17 UDP char src[INET_ADDRSTRLEN], dst[INET_ADDRSTRLEN]; inet_ntop(AF_INET, buf + 12, src, sizeof src); inet_ntop(AF_INET, buf + 16, dst, sizeof dst); printf("%s -> %s proto %d len %u (read %zd, hdr %d)\n", src, dst, proto, ntohs(total), n, ihl); } }

Run it, then in another terminal ping 100.64.0.2. The kernel routes the echo request to tun0. Your program prints 100.64.0.1 -> 100.64.0.2 proto 1 len 84 once a second. Nobody answers, because nobody is on the other end yet. You are the other end.

✅
Treat every read as exactly one packet. Never assume a fixed size, never try to read "the rest" of a packet with a second call, and never trust total from the header without checking it against n. A buffer of 2048 bytes covers any packet on a standard 1500-byte MTU link with room to spare.

The path of one packet

Step through the visualization. One datagram goes from an app on machine A to a server on machine B. Watch which process holds the bytes at each step, which routing-table row wins, and how the inner packet becomes the payload of an outer one.

tun — one packet, two machines, two fds
Click next to start the walkthrough.

Two fds, one loop

A daemon that only reads packets is a packet sink. To build a tunnel it needs a second fd: a UDP socket to the peer's real address. Then the job is symmetric. A packet from tun0 goes out through the socket. A datagram from the socket goes into tun0. Two fds, both can block, so you watch them with poll exactly as in lesson 08.

c
#include <poll.h> #include <sys/socket.h> #include <netinet/in.h> // tunfd: from tun_open("tun0") // udpfd: socket(AF_INET, SOCK_DGRAM, 0), bound to port 51820 // peer: the other machine's real address, learned via NAT traversal (lesson 11) void tunnel_loop(int tunfd, int udpfd, struct sockaddr_in peer) { unsigned char buf[2048]; struct pollfd fds[2] = { { .fd = tunfd, .events = POLLIN }, { .fd = udpfd, .events = POLLIN }, }; for (;;) { if (poll(fds, 2, -1) == -1) { perror("poll"); return; } if (fds[0].revents & POLLIN) { // an app on this machine sent to 100.64.x.x → encapsulate and ship it ssize_t n = read(tunfd, buf, sizeof buf); if (n > 0) sendto(udpfd, buf, n, 0, (struct sockaddr *)&peer, sizeof peer); } if (fds[1].revents & POLLIN) { // the peer's daemon sent us an inner packet → hand it to the kernel struct sockaddr_in from; socklen_t fl = sizeof from; ssize_t n = recvfrom(udpfd, buf, sizeof buf, 0, (struct sockaddr *)&from, &fl); if (n >= 20 && (buf[0] >> 4) == 4) write(tunfd, buf, n); } } }

Run this on two machines with the right peer addresses and ping 100.64.0.2 gets replies. Forty lines. This is, with one omission, the data plane of WireGuard-go and Tailscale: read from the TUN, send over UDP, receive over UDP, write to the TUN. The omission is what happens to buf between the read and the sendto. Right now it is nothing. Every inner packet crosses the internet in the clear, and anyone who can reach your UDP port can write arbitrary packets into your kernel. That is the next lesson.

⚠️
Set the MTU on tun0 lower than the real link. The outer IP and UDP headers add 28 bytes, and encryption will add more. A 1500-byte inner packet becomes a 1528-byte datagram, which the real link cannot carry in one frame. It gets fragmented or dropped. ip link set tun0 mtu 1420 makes the kernel build inner packets that still fit after wrapping. WireGuard's default is exactly that number.
💡
TUN carries IP packets. TAP carries Ethernet frames. With IFF_TAP instead of IFF_TUN, each read starts with the 14-byte Ethernet header from lesson 01, and your program has to deal with MAC addresses and ARP. Useful for bridging virtual machines onto a LAN. For a VPN that routes at the IP level, TUN is simpler and 14 bytes per packet cheaper.
one-line takeaway

A TUN device is a network interface whose wire is a file descriptor: the kernel routes IP packets into your read, and whatever you write arrives as if from the network.