Build a TCP/IP Stack from Scratch · Module 02

Crafting and Sending an ARP Reply

Crafting and Sending an ARP Reply — Your First Outgoing Frame

When an ARP request targets your IP, your stack should answer with an ARP reply that contains your MAC and IP. We'll add three things:

  1. read our MAC from tap0,
  2. detect "ARP request for me",
  3. craft and write() a correct Ethernet+ARP reply back out the TAP fd.

7.1 Read the interface MAC (once at startup)

Create stack/ifutil.h:

#pragma once
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <net/if.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <errno.h>
 
// Fill mac[6] with the interface MAC (e.g., "tap0")
static inline int get_iface_mac(const char *ifname, unsigned char mac[6]) {
 int s = socket(AF_INET, SOCK_DGRAM, 0);
 if (s < 0) { perror("socket"); return -1; }
 
 struct ifreq ifr;
 memset(&ifr, 0, sizeof(ifr));
 strncpy(ifr.ifr_name, ifname, IFNAMSIZ-1);
 
 if (ioctl(s, SIOCGIFHWADDR, &ifr) < 0) {
 perror("ioctl SIOCGIFHWADDR");
 close(s);
 return -1;
 }
 memcpy(mac, ifr.ifr_hwaddr.sa_data, 6);
 close(s);
 return 0;
}

7.2 Build the ARP reply and send it on the wire

Open stack/main.c and extend it. We'll:

  • keep a MY_IP (the IP you assigned to tap0 earlier),
  • fetch MY_MAC at startup,
  • and add a send_arp_reply() helper.
#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include <string.h>
#include <arpa/inet.h>
#include "tap.h"
#include "eth.h"
#include "arp.h"
#include "ifutil.h"
 
// Configure this to match tap0 (set in "ip addr add 10.10.0.1/24 dev tap0")
static const uint8_t MY_IP[4] = {10, 10, 0, 1};
static uint8_t MY_MAC[6] = {0}; // filled at runtime
 
static int ip_eq(const uint8_t a[4], const uint8_t b[4]) {
 return a[0]==b[0] && a[1]==b[1] && a[2]==b[2] && a[3]==b[3];
}
 
static void send_arp_reply(int fd,
 const struct eth_hdr *req_eth,
 const struct arp_hdr *req_arp)
{
 // Ethernet (14) + ARP (28) = 42 bytes
 uint8_t frame[42];
 
 struct eth_hdr *eth = (struct eth_hdr*)frame;
 struct arp_hdr *arp = (struct arp_hdr*)(frame + sizeof(struct eth_hdr));
 
 // --- Ethernet header ---
 memcpy(eth->dmac, req_eth->smac, 6); // back to requester
 memcpy(eth->smac, MY_MAC, 6); // from us (tap0 MAC)
 eth->ethertype = htons(0x0806); // ARP
 
 // --- ARP payload (reply) ---
 arp->htype = htons(1); // Ethernet
 arp->ptype = htons(0x0800); // IPv4
 arp->hlen = 6;
 arp->plen = 4;
 arp->oper = htons(2); // reply
 
 memcpy(arp->sha, MY_MAC, 6); // sender HW: us
 memcpy(arp->sip, MY_IP, 4); // sender IP: us
 memcpy(arp->tha, req_arp->sha, 6); // target HW: requester
 memcpy(arp->tip, req_arp->sip, 4); // target IP: requester IP
 
 ssize_t sent = write(fd, frame, sizeof(frame));
 if (sent < 0) perror("write ARP reply");
 else printf("Sent ARP reply (%zd bytes)\n", sent);
}
 
static void handle_frame(int fd, const unsigned char *buf, ssize_t n) {
 if (n < (ssize_t)(sizeof(struct eth_hdr) + sizeof(struct arp_hdr))) return;
 
 const struct eth_hdr *eh = (const struct eth_hdr*)buf;
 uint16_t et = ntohs(eh->ethertype);
 if (et != 0x0806) return; // only ARP here
 
 const struct arp_hdr *ah = (const struct arp_hdr*)(buf + sizeof(struct eth_hdr));
 uint16_t op = ntohs(ah->oper);
 
 // We only care about "Who has <MY_IP>?"
 if (op == 1 && ip_eq(ah->tip, MY_IP)) {
 printf("ARP Request: who has %d.%d.%d.%d? tell %d.%d.%d.%d\n",
 ah->tip[0], ah->tip[1], ah->tip[2], ah->tip[3],
 ah->sip[0], ah->sip[1], ah->sip[2], ah->sip[3]);
 send_arp_reply(fd, eh, ah);
 }
}
 
int main(void) {
 int fd = tap_open("tap0");
 
 if (get_iface_mac("tap0", MY_MAC) != 0) {
 fprintf(stderr, "Failed to read tap0 MAC\n");
 return 1;
 }
 char macbuf[18];
 mac_to_str(MY_MAC, macbuf);
 printf("tap0 MAC = %s\n", macbuf);
 
 unsigned char buf[2048];
 while (1) {
 ssize_t n = read(fd, buf, sizeof(buf));
 if (n < 0) { perror("read tap"); break; }
 handle_frame(fd, buf, n);
 }
 return 0;
}

7.3 Build and run

docker compose exec stack bash -lc 'cd /stack && make && ./bin/stack'

You should see something like:

TAP up as tap0 (fd=3)
tap0 MAC = 7a:9a:ee:3a:cb:1d

Generate an ARP request on tap0 (local sanity check):

docker compose exec stack bash -lc 'arping -I tap0 -c 1 -S 10.10.0.2 10.10.0.1 || true'

Expected logs in your stack process:

ARP Request: who has 10.10.0.1? tell 10.10.0.2
Sent ARP reply (42 bytes)

Note: End-to-end from the client container to your TAP will require wiring traffic between eth0 and tap0 (we'll do that shortly). This local arping merely proves your reply path works and your frame is well-formed.

Expected logs from arping:

root@d3de86cd457a:/stack# arping -I tap0 -c 1 -S 10.10.0.2 10.10.0.1
ARPING 10.10.0.1
42 bytes from 4e:85:9f:35:b4:40 (10.10.0.1): index=0 time=192.917 usec
 
--- 10.10.0.1 statistics ---
1 packets transmitted, 1 packets received, 0% unanswered (0 extra)
rtt min/avg/max/std-dev = 0.193/0.193/0.193/0.000 ms

Congratulations! You've just created your first outgoing network frame. Your stack is now officially alive on the network!