Network Tutorial · 04

Four bytes were enough

IPv4 is the envelope that carries every reply past the local wire. Its handler is almost entirely a list of headers to refuse — and this is the chapter where we say, plainly, which address format we will never carry.

[o-o] |=| /_\

ARP found the neighbour, the wall handed us a safe copy, ICMP taught us the checksum. All of it rode inside an IPv4 packet without us looking. Now we look. And validating an IPv4 header, it turns out, is the same act as our whole philosophy: it is a list of reasons to throw the packet away.

The envelope inside the envelope

Ethernet delivers a frame to a machine on the same wire. IPv4 is the layer that addresses a specific host — 192.168.111.2 — and, in a bigger world, lets routers pass the packet hop to hop. Inside our Ethernet frame sits a 20-byte IPv4 header: version and header length in one byte, total length, an identifier, flags and fragment offset, a time-to-live, the protocol of what's inside (1 = ICMP, 17 = UDP, 6 = TCP), a header checksum, and then the two addresses that matter, source and destination. Everything above — ICMP, UDP, TCP — is just the header's payload.

Validation is refusal

Before any higher protocol sees a byte, net_ipv4_rx_validate() takes the header apart and looks for an excuse to stop. It is the guard-clause temperament again, now with a counter behind every door so a dropped packet leaves a fingerprint (src/network/protocol.c):

if (frame_length < NET_ETH_HEADER_SIZE + NET_IPV4_HEADER_MIN_SIZE) {
    ++g_net_diag_stats.ipv4_drop_hdr;   return 0;   /* too short to be IPv4 */
}
if (!net_mac_equal(&frame[0], local_mac)) {
    ++g_net_diag_stats.ipv4_drop_not_us; return 0;  /* not addressed to us */
}
ip = &frame[NET_ETH_HEADER_SIZE];
if ((ip[0] >> 4) != 4u) {                            /* version must be 4 */
    ++g_net_diag_stats.ipv4_drop_hdr;   return 0;
}
ip_header_length = (uint32_t)(ip[0] & 0x0Fu) * 4u;
total_length     = net_read_be16(ip, 2u);
if (ip_header_length < NET_IPV4_HEADER_MIN_SIZE      /* lengths must be sane */
    || total_length < ip_header_length
    || NET_ETH_HEADER_SIZE + ip_header_length > frame_length
    || NET_ETH_HEADER_SIZE + total_length     > frame_length) {
    ++g_net_diag_stats.ipv4_drop_hdr;   return 0;
}
if (!net_ipv4_equal(&ip[16], local_ip)) {            /* is it for our IP?   */
    ++g_net_diag_stats.ipv4_drop_not_us; return 0;
}
if ((net_read_be16(ip, 6u) & NET_IPV4_FLAG_FRAGMENT_MASK) != 0u) {
    ++g_net_diag_stats.ipv4_drop_hdr;   return 0;    /* we do NOT reassemble */
}
if (net_checksum(ip, ip_header_length) != 0u) {      /* header must verify   */
    ++g_net_diag_stats.ipv4_drop_csum;  return 0;
}

Every length is checked against the actual frame we hold, not against what the header claims about itself — a header that says "I am 900 bytes" inside a 60-byte frame is a classic parser trap, and here it is simply the door marked ipv4_drop_hdr. Only a packet that is the right version, addressed to us, internally consistent, unfragmented, and correctly checksummed earns a pointer to its payload and a protocol number. The counters — ipv4_drop_hdr, _not_us, _csum — mean that later, when something is wrong, "IPv4 is dropping packets" is a specific number with a specific reason, not a shrug.

We do not reassemble fragments

That one line — drop anything with the fragment flag or a fragment offset — is a deliberate refusal, not a missing feature. IP fragmentation lets a packet be split across several frames and rebuilt by the receiver, and fragment reassembly is one of the oldest, richest sources of network security holes ever written: overlapping fragments, tiny fragments, fragments that never complete and sit in memory waiting to exhaust it. On a local wire with a normal MTU we never need to receive a fragment, so we accept none. A protocol you don't implement is an attack surface you don't have. When Alquist builds packets, it likewise keeps every datagram inside one frame. No reassembler, no reassembler bugs.

Four bytes were enough

Now the promised paragraph, said plainly. An IPv4 address is four bytes — 32 bits, about four billion of them. You have heard, for thirty years, that we "ran out." We did, and the internet kept working anyway, because a handful of addresses behind a router quietly serve a whole building, a whole home, a whole company. The shortage was real and it was survivable, and four bytes still connect the planet today.

Alquist speaks IPv4, and only IPv4. There is no IPv6 here, and there will be none — not as a hidden "just in case," not as a checkbox. This is a judgement call, made by someone who watched IPX rise, dominate an era of local networks, and vanish without ceremony, and who fully expects to outlive v6 the same way. A hundred and twenty-eight bits of address to solve a problem that four bytes and a router already solved, hauling along its own neighbour-discovery, its own autoconfig, its own second parallel stack to maintain and to attack — that is weight we will not carry on spec. If the world genuinely moves and a real device genuinely needs it, a machine can add a protocol in an afternoon. Until then, every byte of code we don't write for a protocol we don't believe in is a byte that stays on the floppy and off the threat model.

We keep what a small, connected device actually needs, and we refuse the rest on purpose. "Four bytes were enough" is not nostalgia. It is the same discipline as the dropped fragment and the silent handler: the cheapest code to secure is the code that isn't there.

Try it

# Watch the IPv4 headers in full: version, TTL, id, flags, protocol.
sudo tcpdump -nvi eth0 host 192.168.111.2

# TTL on outbound replies is 64; the id increments per packet.
ping -c 2 192.168.111.2

On transmit, Alquist writes a plain 20-byte header: version 4, TTL 64, a per-packet id, the don't-fragment-relevant flags cleared, and a freshly computed header checksum (net_ipv4_build_frame()). Nothing exotic, nothing optional. If tcpdump shows the request arriving but no reply leaves, and ICMP looked fine in Chapter 3, the drop is almost always here — read the ipv4_drop_* counters and the door will tell you which check failed.

[^-^] |=| /_\

Envelope opened, addressed, and checked five ways; fragments and v6 shown the door. We have a validated payload and a protocol number in our hand. Next we spend it on the simplest transport there is: fire a datagram and forget it.