Create and Attach a TAP Device
Create and attach a TAP device (your first "virtual NIC")
Now we're giving your program a wire.
You'll open /dev/net/tun, ask the kernel for a TAP interface named tap0, and get back a file descriptor that delivers raw Ethernet frames — literally the binary packets your network card would normally handle.
4.1 Minimal C: open TAP and print when frames arrive
Create stack/tap.h:
#pragma once
#include <linux/if_tun.h>
#include <linux/if.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
static int tap_open(const char *ifname) {
struct ifreq ifr;
int fd = open("/dev/net/tun", O_RDWR);
if (fd < 0) { perror("open /dev/net/tun"); exit(1); }
memset(&ifr, 0, sizeof(ifr));
ifr.ifr_flags = IFF_TAP | IFF_NO_PI; // L2 frames, no extra header
if (ifname && *ifname) {
strncpy(ifr.ifr_name, ifname, IFNAMSIZ);
}
if (ioctl(fd, TUNSETIFF, (void *)&ifr) < 0) {
perror("ioctl TUNSETIFF");
exit(1);
}
printf("TAP up as %s (fd=%d)\n", ifr.ifr_name, fd);
return fd;
}Update stack/main.c:
#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include "tap.h"
int main(void) {
int fd = tap_open("tap0");
unsigned char buf[2048];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n < 0) { perror("read tap"); break; }
printf("[tap0] got %zd bytes\n", n);
// Print the first 14 bytes (Ethernet header) as hex
for (int i = 0; i < n && i < 14; i++) printf("%02x ", buf[i]);
printf("\n");
}
return 0;
}Build and run inside the stack container:
docker compose exec stack bash -lc 'cd /stack && make && ./bin/stack'You'll see:
TAP up as tap0 (fd=3)
That means your program successfully created the interface!
Aside: What's a file descriptor, really?
In Unix, everything is a file — including network interfaces, devices, and pipes.
When you call open("/dev/net/tun", O_RDWR), the kernel returns an integer, the file descriptor (fd).
fd = 3just means "this is the 4th open file" (0 = stdin, 1 = stdout, 2 = stderr).read(fd, buf, 2048)reads bytes from the TAP driver like a file.write(fd, buf, n)would send bytes onto the virtual wire.
Same I/O model for text files, sockets, and Ethernet frames — that's the Unix superpower.
New to C? Key lines explained
-
memset(&ifr, 0, sizeof(ifr));Zero-initialize theifreqstructure so there's no garbage data. -
struct ifreq ifr;Kernel-facing descriptor for "do something to this interface." Comes from<linux/if.h>. -
ioctl(fd, TUNSETIFF, (void *)&ifr)ioctlis "I/O control" — a special device command. Here it says: "Create a TAP device using the flags and (optional) name inifr." -
IFF_TAP | IFF_NO_PILayer-2 frames please, and no extra 4-byte "packet info" header — we'll parse the raw frames ourselves. -
exit(1)Bail out on failure. Later we'll improve error handling.
This is standard low-level Linux networking C: tiny syscalls, close to the metal.
4.2 Bring the interface up
We'll have Docker apply the ARP behavior for us at container start, so the kernel won't answer ARP on our behalf.
Edit lab/docker-compose.yml (stack service):
stack:
build:
context: ..
dockerfile: lab/stack.Dockerfile
command: sleep infinity
cap_add: [ "NET_ADMIN" ]
devices:
- /dev/net/tun
sysctls:
net.ipv4.conf.all.arp_ignore: "8"
net.ipv4.conf.default.arp_ignore: "8"
networks:
labnet:
ipv4_address: 10.10.0.4
volumes:
- ../stack:/stackRestart the lab so sysctls take effect:
cd lab
docker compose up -d --buildNow, in another terminal, bring tap0 up and assign an IP (no sysctl call needed inside the container):
docker compose exec stack bash -lc '
ip link set tap0 up
ip addr add 10.10.0.1/24 dev tap0 || true
ip -brief addr show tap0
'You should see tap0 with 10.10.0.1/24.
Because we set arp_ignore=8 via compose for all and default, the kernel won't answer ARP on tap0; your code will.
4.3 Generate a frame to see activity
Let's shove a packet across the wire and watch your program catch it.
From inside the stack container:
arping -I tap0 -c 1 10.10.0.2 >/dev/null || trueYour running program should print something like:
[tap0] got 58 bytes
ff ff ff ff ff ff 0e fa a3 02 a3 17 08 06
That's an Ethernet broadcast (ff:ff:ff:ff:ff:ff) carrying ARP (EtherType 08 06).
Note: If you've run the arping command you might have captured two frames:
[tap0] got 70 bytes 33 33 00 00 00 02 0e fa a3 02 a3 17 86 dd [tap0] got 58 bytes ff ff ff ff ff ff 0e fa a3 02 a3 17 08 06That first one is actually an IPv6 multicast, it's basically MLD/Neighbor Discovery chatter that Linux sends when an interface comes up. This is totally normal and reflects how a real interface works. We could disable them just like we disabled the ARP response from the kernel, but they won't bother us since we are not dealing with IPv6 (yet).
Aside: What's happening here
arping builds a broadcast ARP request in the kernel network stack and transmits it on tap0.
The TAP driver hands those bytes straight to your program's file descriptor; your read() loop logs them.
You've just intercepted raw network traffic — the very bytes a physical NIC would see.
4.4 What you just achieved
Created a real network interface (tap0) completely in user space.
Read and printed raw Ethernet frames from it.
Sent packets that your program intercepted directly from the kernel driver.
Huge milestone: you've crossed from networking user to networking implementer.
Next up: parsing those 14 bytes into a proper Ethernet header (dst MAC, src MAC, EtherType) — and laying the groundwork for your own ARP responder.