A userspace TCP/IP stack written in C++17: Ethernet framing, ARP, IPv4, TCP, UDP, and ICMP, all hand-implemented over a Linux TAP device. No kernel sockets are involved in the connections it serves. It runs against the real Linux kernel as a peer, and has been verified doing so.
The point was to stop treating send() and recv() as opaque, and implement what the
kernel actually does underneath them.
graph TD
APP["Application<br/><i>epoll-server</i>"]
NS["NetworkStack<br/><i>identity, demux, connection table, TAP I/O</i>"]
TCPC["TcpConnection<br/><i>the TCP state machine</i>"]
UDPS["UdpSocket"]
ICMP["Icmp"]
TCP["Tcp"]
UDP["Udp"]
IP["Ip"]
ARP["Arp"]
ETH["Ethernet"]
TAP["TAP device<br/><i>/dev/net/tun</i>"]
APP -->|"listen / accept / connect / bind_udp"| NS
NS --> TCPC
NS --> UDPS
NS --> ICMP
TCPC --> TCP
UDPS --> UDP
TCP --> IP
UDP --> IP
ICMP --> IP
IP --> ETH
ARP --> ETH
NS --> ARP
ETH --> TAP
TCP. The full RFC 793 state machine, including a real CLOSING state for
simultaneous close rather than folding it into FIN_WAIT_2. On top of that:
- A sliding window bounded by
min(cwnd, peer's advertised window), in bytes - Slow start, congestion avoidance, and fast retransmit / fast recovery on three duplicate ACKs (RFC 5681) - with the growth and decrease policy itself behind an interface, and two implementations: classic Reno and CUBIC (RFC 8312), the latter the default because it is what Linux has run since 2006 and so what the peers on the other end of these connections are actually running
- Adaptive retransmission timeout (RFC 6298): round-trip time is sampled from the ack clock and smoothed with Jacobson and Karels' estimator, tracking both the mean and its variation, with Karn's algorithm rejecting the ambiguous samples that come from retransmitted segments and exponential backoff covering the resulting blind spot
- Out-of-order reassembly as disjoint byte ranges, so overlapping and partially overlapping segments are held once and keep every new byte, bounded by a fixed receive-buffer capacity that also determines the advertised window
- MSS and window scale negotiation (RFC 7323), including the rule that scaling applies only if both SYNs carried the option
- Timestamps and PAWS (RFC 7323): a round-trip sample from every segment rather than one per window, which restores RTT measurement during loss recovery - exactly when Karn's algorithm otherwise blinds the estimator - and rejects an old duplicate whose sequence number looks plausible because the space has wrapped
- Selective acknowledgement (RFC 2018), both directions. The cumulative ack can say "I have everything below this" and nothing else, so one lost segment hides the eight behind it that arrived perfectly; SACK names what did arrive, so only the holes are resent
- SACK-based loss recovery (RFC 6675) when SACK is negotiated: a per-segment
scoreboard (
SendScoreboard) with IsLost(), the pipe estimate and NextSeg() rules 1-4, rescue retransmission included; recovery entered on three duplicates or as soon as IsLost(HighACK+1) holds, and left at RecoveryPoint. After a timeout the SACK information is kept (section 5.1): every unSACKed segment is presumed lost and refilled in order, skipping what the peer holds. How much each ack may send during recovery is Proportional Rate Reduction (RFC 6937, with the slow start reduction bound), towards the ssthresh the Reno or CUBIC policy chose. Without SACK it is NewReno (RFC 6582): a partial ack resends the next hole and stays in recovery. Three losses in one flight cost three retransmissions and no timeout (SimRecoveryin the test suite) - RACK-TLP (RFC 8985) alongside it when SACK is negotiated. RACK detects loss by
time: once a segment sent later has been delivered and a reordering window
(min_RTT/4, or 0 in recovery) has passed, an earlier one is lost - a retransmission
included, which RFC 6675's counting cannot see, so a lost retransmission no longer
waits for the RTO. Candidates sit in a transmit-time list, so an ack costs what it
changes rather than a walk of the flight however the peer shapes its SACK blocks, and
a reordering timer catches what no ack would. A Tail Loss Probe handles the loss
nothing follows: when an ack is overdue (2*SRTT, never later than the RTO) one probe
goes out - new data, or the last segment again - so its SACK can tell RACK what is
missing, and a loss the probe repaired by itself still reduces cwnd. In
SimRecoverya lost retransmission now completes in 230 ms instead of 490 (no RTO), a lost tail in 430 ms instead of 930, and 5% random loss over five seeds takes 2 RTOs instead of 12 - ECN (RFC 3168): negotiated on the SYNs, ECT(0) on new data only (never on SYNs,
pure acks, retransmissions or probes), CE echoed as ECE until the sender's CWR, and
ECE answered like a loss - the congestion-control policy's ssthresh - at most once per
window, so an ECE flood earns one reduction per round trip.
NetworkStack::set_tcp_ecn()takes Linux'stcp_ecnmodes: 0 off, 1 ask and accept, 2 accept only - the default, as on Linux, since asking can meet middleboxes that drop ECN-setup SYNs. A simulated link that marks instead of dropping carries a transfer with no retransmissions while the window still comes down. Forwarding leaves the TOS byte, ECN field included, as it found it - Keepalive (RFC 1122 4.2.3.6), off by default as the RFC requires, probing one byte behind SND.NXT because that is the only segment that compels a reply without moving the stream
- Delayed ACK and Nagle's algorithm, deliberately guarded against their well-known pathological interaction
- A zero-window persist timer, whose probe byte is intentionally kept outside the in-flight window so it cannot trip the retransmit give-up or the duplicate-ack machinery
- Unpredictable initial sequence numbers (RFC 6528):
ISN = M + F(4-tuple, secret), with a SipHash-2-4 keyed PRF forF. The clock term keeps successive connections on one 4-tuple ordered, exactly as RFC 793 intended; the keyed term means an attacker who can watch ISNs for their own 4-tuple learns nothing about anybody else's - which is the hole Morris described in 1985 and Mitnick used in 1994 - Checksum verification on receive, over the bytes as received rather than a re-serialization, and RFC 793 section 3.4 RST generation for segments matching no connection
- Segment acceptance per RFC 9293 and RFC 5961. In
SYN_SENTa RST counts only if it acknowledges our SYN, and a bad ACK is answered with<SEQ=SEG.ACK><CTL=RST>; simultaneous open goes throughSYN_RECEIVED, completing only from a segment at the expected sequence number (and, for the peer's SYN-ACK, with the options its SYN offered). On an open connection a RST resets only at exactly RCV.NXT, and an in-window RST off it, a SYN, or an ACK more than the peer's largest window behind SND.UNA draws a challenge ACK instead - throttled per stack to about 1000 a second, each second's limit randomised (Linux's CVE-2016-5696 fix), and counted TIME_WAITignores RST (RFC 1337): RFC 9293 still closes on one and names RFC 1337 as optional; this stack takes its side, because closing early is the hazardTIME_WAITexists to preventabort()(RFC 9293 ABORT: a RST at SND.NXT, every queue flushed) alongsideclose(), which is a half close -shutdown(SHUT_WR), not POSIXclose(). After a ustackclose()(abandon_receive()), new data from the peer is answered with a RST (RFC 9293 3.10.7.4), as is data left unread at close time; aftershutdown(SHUT_WR)it is still delivered- Data is sized so that payload plus options fits the MSS (RFC 6691); urgent data is delivered inline and URG is never sent (RFC 6093)
- A frame the device drops for a passing reason (
ENOBUFS,EAGAIN, a link that is down) is counted as loss and recovered by retransmission rather than thrown at the application; only a device that is gone surfaces, asFatalChannelError
IP. Header parse and serialize, checksum computation and verification, and fragmentation in both directions - splitting oversized outbound datagrams, and reassembling inbound ones. Reassembly refuses overlapping fragments rather than resolving them, which is the defence against the fragment-overlap evasion class, and bounds how much a peer can make it hold for datagrams that never complete.
ARP. Request and reply in both directions, with a time-based TTL refreshed on received traffic, so an actively-talking peer never ages out mid-conversation. The table is bounded, and the stack only learns from ARP that targets its own address, so a shared segment cannot fill it or overwrite mappings it is using. A newly configured address (static, or from a DHCP bind) is announced as RFC 5227 section 2.3 describes: a broadcast request with sender and target IP both the address, sent twice, 2 s apart, so neighbours holding a stale mapping for it replace it. Before a DHCP bind that announcement is preceded by the RFC 5227 probe (below); a static address is announced without one.
Two transports. A TAP device, or an AF_PACKET socket on a physical NIC, both behind
one PacketChannel seam that nothing downstream can distinguish. The real-NIC path
adopts the interface's own hardware address, which is what lets the card filter frames in
hardware and is why promiscuous mode is never needed. See
docs/running-on-a-real-nic.md.
UDP. A connectionless UdpSocket with bind, send_to, and a receive callback.
Sending to a peer whose MAC is not yet known queues the datagram and kicks off a
bounded ARP resolution, the same way an active TCP open does.
ICMP. Echo Request and Reply (so a real ping gets an answer), Destination
Unreachable / Port Unreachable both generated and acted upon, and Time Exceeded for a
datagram whose fragments never all arrived. A datagram to this host with a protocol
nothing handles draws Protocol Unreachable (RFC 1122 3.2.2.1). A header whose option
list cannot be walked - a type octet with no length, a length under 2 or running past
the header - is discarded with Parameter Problem, the pointer naming the octet (RFC 792,
1122 3.2.2.5, 1812 4.3.2.7); unknown but well-framed options are ignored, and other bad
headers (version, IHL, length, checksum) are dropped silently. Every error obeys the same
rules (never about a broadcast, a non-first fragment or another error) and shares one
rate limit, and quotes the offending packet's header and first 8 bytes as received -
for a reassembled datagram, the first fragment's. Receiving a Destination Unreachable for a
connection's own segment fails that connection immediately instead of letting it grind
through the full retransmit budget.
DHCP. A full client (RFC 2131): DISCOVER, OFFER, REQUEST, ACK, then T1 renewal, T2
rebinding and lease expiry. It configures the stack itself - address, mask, gateway and
MTU arrive together and go straight into configure_interface(), which is why that had
to be reconfigurable before this could exist.
A fresh address is not bound at once: after the ACK the client sends three ARP probes (sender 0.0.0.0, a random 0-1 s start, 1-2 s apart) and waits 2 s more for an objection (RFC 5227 2.1.1). Another host replying for the address, or probing for it too, makes the client send DHCPDECLINE, wait 10 s (RFC 2131 3.1.5) and start discovery over; otherwise the lease binds and the gratuitous announcement runs. Renewals are not probed.
The two round trips are not redundant, and the three timers are not one timer with decoration. The REQUEST is broadcast even though the client has already chosen a server, because that is how the servers whose offers were declined learn to release the addresses they had reserved. And T1 (unicast to the granting server) is separate from T2 (broadcast to any server) so that a dead DHCP server is a degradation rather than an outage: the client keeps working for seven eighths of a lease while the network gets a chance to notice.
Its parser is the most exposed one in the stack - tag/length/value with an attacker-chosen length, accepted from any source, before the host even has an address, with nothing in the protocol authenticating any of it. The fuzz suite points at it for exactly that reason, and every option accessor is total: a wrong-sized option reads as absent rather than as whatever is next in memory.
DNS. A stub resolver (RFC 1034/1035): A records over UDP, CNAME chasing, retry across multiple servers, and a bounded TTL-respecting cache. Servers come from the DHCP lease's option 6, so name resolution configures itself along with the address.
Two things here are worth more than the request/response plumbing. Name compression is the format's one genuinely dangerous feature - a label byte with its top two bits set is not a label but a 14-bit offset back into the same attacker-controlled buffer, which is the source of essentially every DNS parser CVE. The defence is layered: a pointer must point strictly backwards (so a cycle is impossible by construction, not merely bounded), the jump count is capped, and the decompressed name is capped at 255 bytes. Deleting both of the first two makes the parser hang on a 14-byte datagram, which the test suite demonstrates.
And the query is made hard to guess. A DNS answer is an unauthenticated datagram and whoever replies first wins, so an off-path attacker who can predict a query can poison the cache. Both unknowns get real entropy from keyed SipHash streams: the transaction id, and the UDP source port - a fresh random one per query rather than one fixed port, which is what takes the attacker from 16 bits of guessing to ~32. That is the gap Kaminsky's 2008 work made unignorable. The two streams keep independent state, because sharing it would collapse the two guesses back into one.
Forwarding - a network element, not just a host. With more than one interface and
--second-device, the stack relays packets between segments: TTL decrement, header
checksum recomputed, re-encapsulated out the link the route names. It shows up in a
traceroute as a hop, because expiring a TTL draws ICMP Time Exceeded code 0 - the
code only a forwarding node can produce, as distinct from the code 1 a host sends for a
reassembly timeout. No route draws Net Unreachable.
Off by default: two interfaces do not mean wanting to relay between them, and on a
machine straddling two networks, forwarding nobody asked for is how a firewall quietly
becomes a bridge. Linux takes the same position with net.ipv4.ip_forward.
A forwarded packet goes out byte-for-byte apart from the TTL and its checksum. That is not an optimisation - rebuilding it from the parsed form would silently shorten a TCP segment carrying options this codec does not model, corrupting a connection the stack is only supposed to be relaying. The source address is not rewritten either: a router forwards, it does not translate, which is what lets the reply come back and what makes every hop visible to a traceroute.
The forwarding decision is made from the IP header alone: the receive path parses a packet's transport header only if it is addressed to this host. So a router relays a protocol it has never heard of, or a TCP or UDP header its own codecs would reject, exactly as it would any other packet; a malformed IP option is the one header error it reports rather than relays.
IPv6, and NDP as ARP redesigned. IPv6Address with RFC 5952 canonical formatting,
the fixed 40-byte header with a bounded extension-header walk, ICMPv6 with the v6
pseudo-header checksum, the full RFC 4861 neighbour cache, and DAD plus SLAAC - wired
into NetworkStack, which is dual-stack (see below).
The reason this is worth having next to the v4 code rather than instead of it is that the
two can be read against each other. ArpTable is a map with a TTL: an entry is present or
expired, and "expired" is decided by a clock that has no idea whether the mapping still
works - so a neighbour that changed its MAC keeps being used until the timer runs out,
while one that has not moved in an hour is discarded and re-resolved for nothing.
The neighbour cache replaces the clock with a question - is this neighbour still reachable? - and answers it from evidence the stack already had. A TCP connection making forward progress is proof the neighbour is receiving. That is what upper-layer confirmation means, and it is the biggest idea in NDP: reachability is not a timer, it is something the layers above already know and were previously throwing away.
Two states are easy to misread. STALE is not an error and not expiring - it is a mapping believed but unverified, used exactly as freely as REACHABLE, and it has no state timer (only a garbage-collection deadline if nobody uses it). What moves it on is sending to it. DELAY exists solely to avoid probing for something a TCP ack is about to prove.
Three more things NDP got right that ARP could not. A solicitation goes to the target's solicited-node multicast group rather than to the whole segment, so every other NIC filters it out in hardware instead of waking its host - and because the v6 multicast to Ethernet mapping is arithmetic rather than a lookup, resolving a neighbour needs no prior resolution. Every NDP message must arrive with a hop limit of 255, which proves it was never forwarded, because a router would have decremented it. And SLAAC lets a host come up with no server at all: it computes its own link-local address from its MAC, asks for a router, and forms a global address from the advertised prefix - with DAD going and checking, which is the half that makes the other half safe.
Dual stack. TCP, UDP and echo run over either family. IpAddress (a tagged union
of the two address classes, not a base of them - a v4 address and its v4-mapped form
compare unequal) keys connections, sockets and the ISN/SYN-cookie hashes, whose v4 inputs
are byte-for-byte what they were. IPv6 is off until enable_ipv6() (EUI-64 link-local,
DAD, router solicitation, SLAAC from RAs with RFC 4862 lifetimes and the two-hour rule) or
add_ipv6_address() (static, no DAD). Then:
- EtherType 0x86DD is accepted for our MAC and the 33:33 MACs of joined groups (all-nodes and the solicited-node group of every held or tentative address), and only for our addresses and those groups.
- TCP and UDP use the RFC 8200 8.1 pseudo-header; a zero UDP checksum is invalid. A v6 SYN advertises MTU - 60. Packet Too Big lowers a connection's MSS (never below 1280 - 60, RFC 8201) and port unreachable fails a handshake, both only for a quoted sequence number in flight, as the v4 errors are (RFC 5927). ICMPv6 errors obey RFC 4443 2.4(e) and share ICMPv4's token bucket.
- Sending resolves through the neighbour cache and queues whole packets behind it, the way ARP holds datagrams. On-link prefixes and default routers come from RAs, with their lifetimes; static addresses add their prefix as on-link.
- NDP is unauthenticated, so everything it feeds is bounded: the neighbour cache (512,
evicting by origin - entries created to answer unverified traffic go first, a router's
or a live connection's never - with separate quotas for resolutions in flight), the
pending queues, routers, prefixes, and SLAAC addresses (their own cap, apart from
static ones). NS, NA and RA need hop limit 255 (RFC 4861); Redirects are ignored.
Packets carrying v4-mapped addresses are dropped, as Linux's
ipv6_rcvdoes. - ustack's AF_INET6 sockets take
sockaddr_in6everywhere and are dual-stack by default, as Linux withbindv6only=0: IPv4 peers appear as::ffff:a.b.c.d.IPV6_V6ONLY(before bind) makes one IPv6-only. A socket bound to a specific address only accepts SYNs and datagrams sent to it.
Routing. A subnet mask, a default gateway, and a route table with longest-prefix match, each route naming the interface to send by. Every send decides whether the destination is on-link, and so resolves the destination itself, or off-link, and so resolves the gateway - the distinction between the address in the IP header and the address the frame is sent to.
Timers in real time, not in polling cycles. The stack takes elapsed milliseconds
from its caller (on_time_passed) rather than counting timer ticks, so every timeout in
it means what its RFC says it means no matter how often, or how regularly, the
application gets round to calling. The distinction is not academic: a tick counter
conflates "how often am I polled" with "how much time has passed", so an event loop that
stalls for two seconds delivers one tick and the stack concludes half a second went by -
retransmissions then run late by exactly however overloaded the machine was.
Timers driven by deadlines, not by a tick. Measuring elapsed time fixed what a tick
means; it left how often one arrives. The epoll server used a 500 ms periodic timerfd,
so 500 ms was the resolution of every timer in the stack: a delayed ACK due 200 ms after
its data went out up to 300 ms late, the whole of RFC 1122's 500 ms ceiling, and a
retransmission at the 200 ms minimum RTO could wait as long again. And every tick walked
every connection to decrement countdowns that were almost never due - linear in the
connection table for nothing. So every timer is now the moment it is due rather than a
countdown. Each TcpConnection reports its earliest deadline whenever one is armed or
disarmed; NetworkStack keeps a min-heap of (deadline, connection id), and
on_time_passed runs only the connections at its front - 5 ns per call with 10,000 idle
connections, against 106 us when it walked them all. ms_until_next_timer() reports how
long until the earliest timer anywhere in the stack (connections, ARP, reassembly, DHCP,
DNS), and both applications sleep exactly that long: the epoll server (through
ustack::RealDriver) re-arms a one-shot timerfd after every pass of its loop and disarms
it when nothing is pending, so an idle server does not wake at all. Moving deadlines are cheap on purpose: an ack that pushes
the retransmit timer later pushes nothing onto the heap - the earlier entry surfaces,
finds nothing due, and is re-queued once per round trip. Heap entries name connections by
id, so one for a connection reaped in the meantime is recognised and dropped rather than
dereferenced.
Congestion control as a policy, not as the protocol. Until it was pulled behind an
interface, TcpConnection knew that a window halved on loss and grew by one MSS per RTT
- which are Reno's answers, not TCP's. The seam sits where the two algorithms actually
disagree, which is only two places: how far the window is cut on congestion, and how it
grows past
ssthresh. Slow start, the fast-recovery bookkeeping and the timeout-is-worse-than-duplicate-acks distinction are RFC 5681's and are shared. Linux'stcp_congestion_opsdraws the line in the same place for the same reason. CUBIC is what proves the seam was cut correctly rather than shaped around Reno: its window is a cubic function of real time since the last congestion event, a quantity this stack could not express at all until its timers stopped counting the application's polling cycles.
Flow control that exists rather than being advertised. Received data waits in a
queue until the application reads it, so an application that stops reading genuinely
closes the window and stops the sender. The window is never shrunk, though (RFC 1122
4.2.2.16, RFC 9293 3.8.6): out-of-order data arrives inside the window already offered, so
it is held without pulling the right edge back, as Linux's tcp_select_window does. When it
did shrink by every buffered byte, each duplicate ACK carried a different window, failed RFC
5681's "window unchanged" test, and fast retransmit never fired between two of these stacks.
The sender also counts an ACK that SACKs new data as a duplicate (RFC 6675's definition), so
a peer that does shrink cannot block fast retransmit either.
Ten segments in the first flight. cwnd starts at RFC 6928's initial window, min(10MSS, max(2MSS, 14600)), for Reno and CUBIC alike; with a one-segment start every connection's first round trip waited out the peer's 200 ms delayed-ack timer. It falls back to one segment after more than one SYN or SYN-ACK retransmission, restarts from min(IW, cwnd) after an idle period longer than the RTO (RFC 5681 4.1), and the loss window after a timeout stays one segment.
Every segment carries its options. One path builds every non-RST segment, so a
retransmission, a zero-window probe or a keepalive carries a timestamp (RFC 7323 3.2) and a
retransmitted SYN offers what the first did. The timestamp echoed follows RFC 7323 4.3
(the earliest unacknowledged segment's, the hole-filling retransmission's) and is taken only
from a segment that passed PAWS and the window check (5.3), so a spoofed out-of-window
segment cannot advance it and make PAWS drop the real ones. send() is bounded too and reports how much it
accepted, and listen() takes a backlog so the accept queue cannot be grown without
limit by a peer.
These are documented decisions, not gaps that were missed:
-
No PRR or RACK-TLP without SACK, and no D-SACK or F-RTO. Without SACK, recovery is NewReno's window inflation and loss is detected by duplicate counts alone; there is no SACK-less pipe estimate for PRR to work from. Without D-SACK, RACK's reordering window stays at min_RTT/4 (RFC 8985's reo_wnd_mult never grows), TLP judges a repaired loss without the D-SACK case, and a spurious retransmission or timeout is not undone.
-
ECN is RFC 3168's, not AccECN or L4S. One reduction per marked window, no per-mark feedback (RFC 9768) and no DCTCP-style proportional response; a sender with a one-segment window does not also reset its retransmit timer on ECE (6.1.2).
-
IPv6 is a host, on one link. It runs on the first interface only, and forwarding stays IPv4-only: a v6 packet for another host is dropped whatever
set_forwarding()says. -
No IPv6 fragmentation. Sends never fragment (PMTUD keeps TCP inside the path MTU; an oversized UDP datagram is refused with EMSGSIZE). A received packet carrying a Fragment header is dropped rather than reassembled - real v6 traffic is rarely fragmented, and a v6 reassembler would be a second copy of the bounded-memory problem
IpReassemblersolves. -
No MLD, DHCPv6 or ICMPv6 Redirect. Group membership is local filtering only, the M flag is ignored, and Redirects are dropped unread.
-
One socket per port, whatever the family. ustack cannot have an
IPV6_V6ONLYlistener and an AF_INET listener on the same port as Linux can; a dual-stack socket serves both. -
No RFC 4941 temporary addresses. SLAAC derives the interface identifier from the MAC, so a host is trackable across networks by the bottom 64 bits of its address. Privacy addresses are the fix and are not implemented.
-
No ECMP. A route is keyed by destination and prefix, so two routes to one prefix by different interfaces cannot coexist. Equal-cost multipath needs a per-flow hash to keep a connection's packets on one path, which is a different project.
-
No ICMP Redirect. When a packet would go back out the interface it arrived on, this stack declines to forward it rather than telling the sender about the better first hop. That is the safe half of the behaviour.
-
TIME_WAITassumes a 30-second MSL, so it runs for 60 seconds rather than RFC 793's 4 minutes. Linux makes the same call, for the same reason: the RFC's figure was calibrated for a network whose delays no longer exist, and holding per-connection state that long is a real cost on a busy server.
Requires a Linux toolchain with C++17, CMake 3.20+ and Ninja. GoogleTest and GMock are
taken from the system package (libgtest-dev libgmock-dev on Debian/Ubuntu, gtest on
Arch) and fetched at a pinned release if none is installed. ccache is used when present.
The build is CMake, with one preset per flavour in CMakePresets.json - debug,
release, asan, tsan, coverage (clang) and strict (-Wextra -Werror) - each in
its own build/<preset> directory. The root Makefile is a thin wrapper over them:
make # build everything (debug preset)
make test # build and run the full test suite
make strict # build everything with -Wall -Wextra -Werror, then run the tests
make coverage # per-file coverage report; HTML in build/coverage/html
make clean
# or directly
cmake --preset debug && cmake --build --preset debug && ctest --preset debug -L unitThe presets build the stack, the test runner and both applications. The applications,
epoll-server/ and http-get/, also keep their own
Makefiles (make -C epoll-server), which is how the end-to-end scripts build them.
There is no native Linux requirement for the tests - they run anywhere, because every
component takes an injectable seam instead of touching the OS directly. Running the
stack itself needs /dev/net/tun and NET_ADMIN. A container works:
docker run --rm -it --cap-add=NET_ADMIN --device=/dev/net/tun \
-v "$PWD:/work" -w /work gcc:latest bashOptional hooks in githooks/ catch what CI would catch, before you push:
pre-commitchecks the staged C/C++ files withclang-format, then runsmake test.pre-pushrunsmake asan(a full clean rebuild, so it is the slow one).
git config core.hooksPath githooks # enable (per clone)
git commit --no-verify # skip pre-commit once
git push --no-verify # skip pre-push onceNeeds clang-format on PATH. The static-analysis checks CI runs are in
.github/workflows/static-analysis.yml; the
clang-tidy configuration is in .clang-tidy.
http-get/ is the demonstrator: a one-shot HTTP client that starts with no
address at all and does the entire job itself.
$ sudo http-get --transport nic --device vhttp0 --dhcp demo.stacktest:8080/index.html
interface vhttp0 mac e2:79:f3:b4:3b:8e
DHCP discovering...
DHCP 10.9.1.56/24 gateway 10.9.1.1 lease 120s
DNS via 10.9.1.1
DNS resolving demo.stacktest...
DNS demo.stacktest is 192.168.88.10
TCP connecting to 192.168.88.10:8080...
TCP established
HTTP GET /index.html
HTTP 24 bytes of body
HTTP/1.0 200 OK
...
Every line there is this project's own code: the DHCP exchange, the DNS query, the ARP
for the gateway (the server is off-link, so the frame goes to the router while the IP
header names the server), the handshake, and the half-close. The kernel's only
involvement is an AF_PACKET socket handing over raw Ethernet frames. curl does this
in one line - the point is that this is the same line with everything underneath it made
visible.
It runs in CI against real dnsmasq and a real HTTP server, neither of which has ever
heard of this codebase.
epoll-server/ is a multithreaded TCP echo server written against the
ustack socket API below, the way one is written against the kernel: accept, read, hand
the work to a thread pool, write the answer back, all in one epoll loop. The logic is a
template over the stack type, so the same source builds against this stack or, with
make -C epoll-server KERNEL=1, against real kernel sockets. Workers never touch the
stack (it is not thread-safe): they compute responses and hand them back through an
eventfd-backed completion queue that the reactor drains. It listens on several ports,
caps concurrent connections, and on SIGINT/SIGTERM flushes responses, half-closes every
connection and waits for the peers (up to 5 s). See its own README.
include/ustack/stack.h is a socket API over the stack with
the kernel's names, signatures and constants - socket, bind, listen, accept4,
connect, send, recv, sendto, recvfrom, shutdown, close, setsockopt
(TCP_NODELAY, SO_KEEPALIVE, TCP_USER_TIMEOUT, SO_LINGER, TCP_CONGESTION,
SO_ERROR), plus its own epoll_create1/epoll_ctl/epoll_wait - returning -1 and
setting errno as Linux would. Its fds are indexes into its own table, not kernel fds.
The epoll is built like the kernel's: an interest list and a ready list filled by
callbacks from the stack, with level-triggered, EPOLLET and EPOLLONESHOT semantics.
It is non-blocking only (socket() requires SOCK_NONBLOCK), because the thread that
would block is the one that drives the stack; epoll_wait() is what runs it, through a
driver that waits on the channel fds and a timerfd, or on the simulator's virtual time in
tests. pollable_fd() lets it nest inside an application's own kernel epoll. The other
direction is epoll_ctl_external(): a kernel fd the application owns (an eventfd, say) is
added to a ustack epoll and the wait sleeps in the driver's kernel epoll until it is ready
(level-triggered; the simulator's driver refuses with ENOTSUP).
include/ustack/kernel_stack.h is ustack::KernelStack:
the same method names and signatures, each forwarding to the real ::socket,
::epoll_wait and so on. Code written as a template over the stack type compiles against
either, which is how the epoll server's tests run one set of scenarios on both, and how
test_stack_differential.cpp records the epoll events, bytes and errno values of one
scenario on each and compares them.
Over 370 GoogleTest unit tests covering the protocol codecs, checksums, the TCP state machine, RTT estimation, congestion control, flow control, keepalive, ISN generation, DHCP lease acquisition and renewal, DNS parsing and anti-spoofing, routing, fragment reassembly, ARP ageing, IPv6 addressing and headers, the NDP neighbour cache, DAD and SLAAC, UDP, ICMP, the thread pool and completion queue, and the logger, plus a fuzz suite asserting no codec crashes on malformed input.
Two seams make the untestable parts testable without any OS involvement: PacketChannel
injects a fake frame transport into NetworkStack, and LoopbackChannel wires two
complete stacks back to back through a real ARP exchange, handshake, data transfer, and
half-close.
tests/include/sim/ is a deterministic network simulator. SimNetwork runs any number of
stacks on virtual time (SimClock; nothing reads the real clock) over NetemChannel links
that, per direction, lose, duplicate, corrupt, reorder, delay and jitter frames, serialize
them at a given rate behind a bounded queue, and drop frames over an MTU. Every decision comes
from one seeded std::mt19937_64, and each stack's secrets (ISN key and clock, SYN-cookie
key) are derived from the same seed through the NetworkStack::set_secret_seed() test seam,
so a seed reproduces a run byte for byte. A test can also pick a stack's secret seed itself,
which is how SimTcp.ATransferCrossesTheSequenceNumberWrap puts both ISNs just below 2^32.
Focused scenarios (a lost SYN-ACK, one lost segment, an odd MTU, the RTO under loss) live in
test_sim_tcp.cpp; NetemChannel::set_drop_filter() drops one chosen frame.
Virtual time advances event to event: each step jumps straight to the earliest of the next
frame due on any link and the next timer due in any stack (ms_until_next_timer()), so a
minute of TIME_WAIT is one step rather than 60,000. Nothing can happen in between, and
SimNetworkTest.EventDrivenSteppingMatchesOneMillisecondSteppingFrameForFrame holds it to
that: a lossy transfer, close and TIME_WAIT produce the same frames, byte for byte and at the
same microsecond, as stepping one millisecond at a time (set_event_driven(false)). A stack
that failed to report a deadline would make the two diverge at that frame.
- Property tests (
test_sim_transfer.cpp, ctest labelsim): per seed, a random link and a random payload of up to 1 MiB, sent one way or echoed; the bytes must arrive intact and in order and both connections must close.make simrunsSIM_SEEDSseeds (default 200, a few seconds) sharded over all CPUs; the nightly workflow runs 100000. Every seed that has ever failed is kept inSimTransferRegression(run by default) with what it found and which fix turned it green. - Conformance scripts (
tests/scripts/*.pkt): packetdrill-style scripts that play the peer of one stack - inject segments, state exactly what must come back and when. The syntax is documented intests/include/sim/pkt_runner.h.tests/scripts/pending/holds scripts for behaviour not implemented yet; run them withPKT_SCRIPTS=tests/scripts/pending ./build/debug/run_tests --gtest_filter='*PktScript*'.
When a simulator test fails it writes build/<preset>/sim-pcaps/<Suite>.<Test>.pcap (frames as
delivered), ...tx.pcap (as transmitted, so the difference is what the link dropped) and a
text rendering of each, and prints the paths - for the first SIM_PCAP_LIMIT failures (default
20; make sim splits it between shards). Later failures are only counted, so a regression in
the nightly run cannot upload hundreds of MB of captures of one bug.
make sim # 200 seeds
SIM_SEEDS=100000 make sim # the nightly run
SIM_SEED_START=1055 SIM_SEEDS=1 make sim # one seed againThe components that cannot be unit tested are the ones that are nothing but syscalls -
the AF_PACKET and TAP transports, and the channel factory that opens them. It has an integration test instead, driving the whole stack over a
veth pair against a peer in its own network namespace. This runs in CI on every push -
it is the only test in the project with a real kernel on the other end, which is exactly
the class of bug the unit suite cannot reach (see "Verified against a real kernel" below).
sudo scripts/veth_test.sh # one segment: the stack as a host
sudo scripts/router_test.sh # two segments: the stack as a router between them
sudo scripts/http_get_test.sh # DHCP + DNS + routing + TCP, against real serversrouter_test.sh is the one that proves forwarding. Two namespaces sit either side of the
stack with no route to each other except through it, and the host kernel owns no address
on either network - so every packet that crosses is one this stack forwarded itself. A
TCP connection between the two real kernels traverses it intact, traceroute names the
stack as a hop, and the same process still terminates its own connections on both links
while relaying between them.
make test
make asan # AddressSanitizer + UBSan + leak detection (rebuilds from scratch)
make tsan # ThreadSanitizer (rebuilds from scratch)
./build/debug/run_tests --gtest_filter='TcpConnection.*' # one suiteASan, UBSan, and leak detection are clean, and CI runs them on every push, alongside
both compilers, a -Werror build of the stack and both applications, a coverage report,
and the end-to-end tests above. They are
worth more here than the pass/fail of the tests: two real bugs, a missing virtual
destructor on a class deleted through a base pointer and enums assigned from wire data
without a fixed underlying type, were both found this way while the entire suite was
passing.
make tsan proves something now, though it did not for a long time: while every test in
the suite was single-threaded, ThreadSanitizer could only observe sequential code and a
clean run meant nothing. The threaded code - epoll-server's thread pool and completion
queue - is now built into the test runner and exercised under real contention, which
needs no TAP device and no privilege, only eventfd. Note also
that TSan aborts at startup ("incompatible memory layout", exit 66) wherever it cannot
disable ASLR. make tsan disables it via setarch -R when permitted; under Docker's
default seccomp profile the personality() syscall is blocked, so run it with:
docker run --security-opt seccomp=unconfined ...tools/loadgen (built as build/<preset>/loadgen) is an epoll-driven echo client over the
kernel's sockets: C connections at a target rate (--rate) and concurrency, payloads of
--size bytes verified byte for byte, --rounds round trips per connection, N threads, or
--duration S for a sustained run. It prints connections/s, verified throughput, connect and
round-trip latency (p50/p90/p99/p99.9/max, log buckets about 3% wide) and errors by type
(refused, timeout, reset, early EOF, echo mismatch), as text or --json, and exits nonzero on
any failure. Unprivileged, against the kernel build of the server on loopback:
make -C epoll-server KERNEL=1 && ./epoll-server/epoll-server-kernel --port 18080 &
./build/debug/loadgen --port 18080 --connections 200 --concurrency 100 --size 4096 --rounds 5 --threads 2Against the real stack, sudo scripts/loadtest_netns.sh runs epoll-server on a veth pair
with loadgen in the peer namespace (a burst, a sustained run, and one under tc netem loss and
delay) and writes JSON reports; the nightly workflow runs it and uploads them.
tests/src/test_soak.cpp keeps a ustack::Stack EchoServer and a client stack alive for two
virtual hours over a lossy, reordering, corrupting link while clients churn (echoes,
half-closes, resets, abandoned and idle connections, connects to a dead host, UDP to a closed
port, unanswered DNS), then stops and requires the resources back at baseline: both
connection tables, the timer heap, accept queues, UDP/resolver/ARP state and the server's
connection list empty, no growth between the first and last third of the run in table sizes,
reorder buffers or heap. It reads those private tables through tests/include/sim/stack_probe.h,
which needs no change to the stack. Event-driven time makes it about 45 s of real time.
make soak runs it (SOAK_SEED, SOAK_MINUTES vary it; ctest label soak), the nightly
workflow runs it with a rotating seed, and a two-minute SoakSmoke version is part of make test.
make mutation rebuilds the unit tests with clang and Mull's
plugin on tcp_connection, send_scoreboard, reorder_buffer, ip and dns, then runs the
suite once per mutant (a flipped comparison, + turned into -, ...). A surviving mutant is
a behaviour no test pins down. It needs clang++-19 and the mull-19 package from Mull's apt
repository (the plugin must match the compiler's LLVM exactly). It is slow, so it runs
nightly, publishes the mutation score in the job summary and uploads the report; there is no
threshold. The job and target were written from Mull's documentation and could not be run
where they were written, so the first nightly is also their first test.
Unit tests are necessary but not sufficient here, and this project has a concrete lesson about why. Every test used two instances of this same stack, which agreed with each other about a checksum computed over a re-serialization of a parsed segment. The Linux kernel does not agree, because a real SYN carries options this codec does not model, so re-serializing it produces different bytes. Every real connection was being rejected as a bad checksum, and no self-consistent test could ever have shown it.
So the stack is also driven end to end against the kernel: a TAP device in a privileged
container with the kernel side addressed, then exercised with ping, nc,
tcpdump, and a concurrent load test. That confirms ping, TCP echo, RST on a closed
port, UDP port unreachable, and 720 out of 720 load connections.
The unit suite's fuzz tests feed each parser a fixed set of random inputs. Real fuzzing
is coverage-guided: libFuzzer keeps the inputs
that reach new branches and mutates those, which is what finds the inputs random bytes
almost never produce. fuzz/ has one harness per parser (Ethernet, IPv4, TCP
with its options, UDP, ICMP, ARP, DHCP, DNS), one that feeds the IP reassembler a
sequence of fragments, and one stateful harness that feeds frames into a whole
NetworkStack with a listening port and an established connection, running poll() and
the timers between frames. Each has a small committed seed corpus in fuzz/corpus/,
generated from the stack's own serializers by fuzz/gen_corpus.cpp.
make fuzz # every harness, 30 s each (needs clang)
FUZZ_SECONDS=300 make fuzz # longerA crash leaves its reproducer in build/fuzz/crashes/<harness>/; replay it with
./build/fuzz/fuzz/fuzz_<harness> <file>. CI runs 20 s per harness on every push, and
the nightly workflow runs about 30 minutes in total with the grown corpus cached between
runs.
bench/ holds Google Benchmark
microbenchmarks (fetched at a pinned release): the Internet checksum at 64, 576, 1500 and
9000 bytes; parsing and serializing Ethernet + IPv4 + TCP with options; parsing a DNS
response with name compression; the TCP reorder buffer, including a many-gaps worst case;
the SACK scoreboard walk; and a simulated bulk transfer through the network simulator
(bytes per wall second, with no loss, 1% and 5% loss, and the simulated completion time
as virtual_ms).
make bench # Release (-O2 -g)
make bench BENCH_ARGS=--benchmark_filter=ReorderOn every push to main, bench.yml runs them and appends
the results to the gh-pages branch with
github-action-benchmark,
which generates a chart per benchmark over commits. The charts are at
https://KoganTheDev.github.io/tcp-ip-stack/dev/bench/. A 1.5x slowdown against the
previous run fails the job and comments on the commit; the threshold is loose on purpose,
because shared runners are noisy. Pull requests run the benchmarks and show the
comparison in the job summary without publishing.
One-time setup for the repository owner: after the first run on main has created the
gh-pages branch, enable Settings > Pages > Build and deployment > Deploy from a
branch > gh-pages / root. Until then the numbers are recorded but there is no page.
Profiled with perf and callgrind under concurrent load, not guessed at:
on_time_passed()visited every connection on every timer tick to decrement its countdowns, nearly all of them for nothing: 106 us per call with 10,000 idle connections. The connection timers are now deadlines in a min-heap, so a call costs the timers that are due - 5 ns with the same 10,000 (bench/bench_timers.cpp)._reap_closed_connections()scanned every connection on every event-loop tick and was 13.1% of total self time, the single largest cost in the binary. Replaced with an event-driven queue fed by the existing state-change callback: O(closed since last reap) instead of O(every connection alive). It no longer appears in the profile.- Per-field heap allocation in serialization: every header field went through a
function that allocated a buffer just for the caller to copy and discard it, about 20
times per packet. Replaced with in-place appends onto a pre-reserved buffer,
cutting
mallocfrom 7.3% to 5.2% and removing several allocator entries from the profile entirely. - Receive-path parse cost cut roughly 56% (4.45B to 1.96B instructions under callgrind)
by removing a hex-string parse from the address default constructors, a per-field
allocation in
slice_int, and payload copies through the parse path.
MIT. See LICENSE.