mytcp

From TAP to curl

Building a userspace TCP stack in Go

How to be an artisan of networking: ditch ultraprocessed packets for organic, locally sourced raw bytes

The life of a curl request

sequenceDiagram participant A as curl participant K as Kernel participant N as Network card participant I as The internet participant G as google.com A->>K: connect(google.com, port 80) Note over K,G: the kernel runs the TCP handshake: three packets A->>K: send("HEAD / HTTP/1.1 …") K->>N: wrapped in TCP, IPv4 and Ethernet headers N->>I: bits on a wire I->>G: router to router G-->>I: HTTP/1.1 301 Moved I-->>N: back the same way N-->>K: one Ethernet frame K-->>A: recv() returns the HTTP text only

curl only ever talks to the kernel. What the kernel does between those calls is what this talk builds.

The networking stack lives in the kernel

Use the HTTP standard library, as a client or a server, in Go, Ruby or Node, and you hand the hard part to the operating system.

  • Your code builds a request. The kernel puts it on the wire: Ethernet, IP, TCP, the retransmits.
  • A userland process only reaches that stack through system calls: socket connect send recv
  • Those calls hide the complexity. They are a narrow API; the bytes on the wire are on the other side of them.

This talk is that other side.

Syscalls hide the complexity

strace -e trace=network curl -4 -sI --max-redirs 0 http://google.com/ · inside Docker

socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 5
connect(5, {sin_port=htons(80),
            sin_addr=inet_addr("142.251.30.113")}, 16) = 0
# ↑ curl said “connect”. The kernel did ARP (maybe cached)
#   and the whole TCP handshake. No syscall per packet.

sendto(5, "HEAD / HTTP/1.1\r\nHost: google.com\r\n"..., 75) = 75
recvfrom(5, "HTTP/1.1 301 Moved Permanently\r\n"
            "Location: http://www.google.com/\r\n"..., ...) = 554
close(5) = 0

curl only sent and received HTTP text. The handshake, the headers and any resends all happened inside the kernel, where curl can’t see them. Next: what that looks like on the wire.

Behind those few syscalls

%%{init: {"sequence": {"messageMargin": 2, "height": 34, "actorMargin": 50, "messageFontSize": 24, "actorFontSize": 22, "boxMargin": 2}}}%% sequenceDiagram participant A as curl participant K as Kernel participant R as Gateway 172.17.0.1 participant G as google.com A->>K: connect(142.251.30.113, port 80) K->>R: Ethernet broadcast · ARP: who has 172.17.0.1? R-->>K: Ethernet · ARP: 172.17.0.1 is at 02:42:f2:e3:9b:63 K->>G: Ethernet · IPv4 · TCP SYN G-->>K: Ethernet · IPv4 · TCP SYN+ACK K->>G: Ethernet · IPv4 · TCP ACK K-->>A: connect() = 0 A->>K: sendto("HEAD / HTTP/1.1 …") · 75 bytes K->>G: Ethernet · IPv4 · TCP · HTTP HEAD · 141 bytes G-->>K: Ethernet · IPv4 · TCP · HTTP/1.1 301 Moved K-->>A: recvfrom() = the HTTP text only A->>K: close() K->>G: Ethernet · IPv4 · TCP FIN, then ACKs both ways

ARP finds the next hop, TCP opens a connection, HTTP rides inside it.

curl handed over 75 bytes. The kernel sent 141.

tcpdump · the request as it left the container · curl → google.com:80

00000000  02 42 f2 e3 9b 63 02 42  ac 11 00 04 08 00 45 00  |.B...c.B......E.|
00000010  00 7f 5e 7f 40 00 40 06  82 78 ac 11 00 04 8e fb  |..^.@.@..x......|
00000020  1e 71 e4 d4 00 50 73 7d  38 96 1b 3b b8 d8 80 18  |.q...Ps}8..;....|
00000030  02 00 59 f3 00 00 01 01  08 0a 16 a0 3f d2 d7 2a  |..Y.........?..*|
00000040  fb a7 48 45 41 44 20 2f  20 48 54 54 50 2f 31 2e  |..HEAD / HTTP/1.|
00000050  31 0d 0a 48 6f 73 74 3a  20 67 6f 6f 67 6c 65 2e  |1..Host: google.|
00000060  63 6f 6d 0d 0a 55 73 65  72 2d 41 67 65 6e 74 3a  |com..User-Agent:|
00000070  20 63 75 72 6c 2f 37 2e  38 38 2e 31 0d 0a 41 63  | curl/7.88.1..Ac|
00000080  63 65 70 74 3a 20 2a 2f  2a 0d 0a 0d 0a           |cept: */*....|
0000008d
BytesFieldValue
0–5Destination MAC (Dst)02:42:f2:e3:9b:63 = the Docker bridge gateway
6–11Source MAC (Src)02:42:ac:11:00:04 = this container
12–13EtherType (Type)08 00 = IPv4
14Version + header length45: 4 = IPv4, 5 × 4 = 20-byte header
15Traffic class00 = ordinary traffic
16–17Total length00 7f = 127: this header + TCP + HTTP
18–19Identification5e 7f
20–21Flags + fragment offset40 00 = don’t fragment
22Time to live40 = 64 hops
23Protocol (Proto)06 = TCP, so the next header is TCP
24–25Header checksum82 78: covers these 20 bytes only
26–29Source IP (Src)ac 11 00 04 = 172.17.0.4
30–33Destination IP (Dst)8e fb 1e 71 = 142.251.30.113
34–37Ports (SrcPort → DstPort)e4 d4 → 00 50 = 58580 → 80
38–41Sequence number (Seq)73 7d 38 96 = the SYN’s start + 1: first byte of this HTTP
42–45Acknowledgment (Ack)1b 3b b8 d8 = 456898776: “got everything before the server’s first byte”
46–47Header length + flags (Flags)80 18: 8 × 4 = 32-byte header · 0x18 = PSH + ACK
48–49Window (Window)02 00 = 512
50–53Checksum + urgent59 f3 · urgent 00 00 (unused)
54–65Options01 01 pad · timestamps (kind 8): TSval / TSecr for RTT
66–140HTTP request75 bytes from sendto: HEAD / HTTP/1.1, Host, User-Agent, Accept, blank line

curl only wrote the green part: the HTTP request. The kernel added the 66 bytes in front of it. Ethernet says which machine on the local network, IPv4 which computer on the internet, and TCP which program, plus where these bytes sit in the stream.

Google’s reply comes back wrapped the same way

tcpdump · the response as it arrived · first 132 of 620 bytes

00000000  02 42 ac 11 00 04 02 42  f2 e3 9b 63 08 00 45 00  |.B.....B...c..E.|
00000010  02 5e 98 55 00 00 3f 06  87 c3 8e fb 1e 71 ac 11  |.^.U..?......q..|
00000020  00 04 00 50 e4 d4 1b 3b  b8 d8 73 7d 38 e1 80 18  |...P...;..s}8...|
00000030  10 00 55 96 00 00 01 01  08 0a d7 2a fb bf 16 a0  |..U........*....|
00000040  3f d2 48 54 54 50 2f 31  2e 31 20 33 30 31 20 4d  |?.HTTP/1.1 301 M|
00000050  6f 76 65 64 20 50 65 72  6d 61 6e 65 6e 74 6c 79  |oved Permanently|
00000060  0d 0a 4c 6f 63 61 74 69  6f 6e 3a 20 68 74 74 70  |..Location: http|
00000070  3a 2f 2f 77 77 77 2e 67  6f 6f 67 6c 65 2e 63 6f  |://www.google.co|
00000080  6d 2f 0d 0a                                       |m/..|
00000084
BytesFieldValue
0–5Destination MAC (Dst)02:42:ac:11:00:04 = this container
6–11Source MAC (Src)02:42:f2:e3:9b:63 = the Docker bridge gateway
12–13EtherType (Type)08 00 = IPv4
14Version + header length45: 4 = IPv4, 5 × 4 = 20-byte header
15Traffic class00
16–17Total length02 5e = 606 (full packet; dump is truncated)
18–19Identification98 55
20–21Flags + fragment offset00 00: may fragment
22Time to live3f = 63: one hop already spent
23Protocol (Proto)06 = TCP
24–25Header checksum87 c3
26–29Source IP (Src)8e fb 1e 71 = 142.251.30.113
30–33Destination IP (Dst)ac 11 00 04 = 172.17.0.4
34–37Ports (SrcPort → DstPort)00 50 → e4 d4 = 80 → 58580
38–41Sequence number (Seq)1b 3b b8 d8 = 456898776: first byte of the response (= client’s earlier ack)
42–45Acknowledgment (Ack)73 7d 38 e1 = curl’s sequence number + 75: “I got all 75 bytes of your request”
46–47Header length + flags (Flags)80 18: 32-byte header · 0x18 = PSH + ACK
48–49Window (Window)10 00 = 4096
50–53Checksum + urgent55 96 · urgent 00 00
54–65Options01 01 pad · timestamps: TSval / echoed TSecr
66–97Status lineHTTP/1.1 301 Moved Permanently
98–131LocationLocation: http://www.google.com/ — the redirect

The kernel strips Ethernet, IPv4 and TCP, and gives curl only the green part: “301, this page has moved to www.google.com”.

It is all one string of bits

From the CPU, through the operating system, down into the networking stack. The same stuff, the whole way.

  • Ones and zeros, arranged so they mean something. A port. A promise to resend. A line of HTTP.
  • Look at the endless stream through a window of fixed length. Computation is that window stepping from one arrangement to the next.
  • The path of those arrangements is the work. One trajectory is a hard problem. Another is just hello world, drawn on a screen.

I think of that as a fundamental theorem of computation.

How I got here

KubeCon

A network engineer from Oracle stitched together all kinds of networks from primitives, live, to explain container networking.

Networking stopped looking like magic. It was parts.

AWS re:Invent

A talk on SRD, the transport AWS runs on its Nitro cards in place of TCP’s single ordered path.

So TCP is a choice, not a law of nature.

I wanted to own the bytes myself, from the wire up.

Why rebuild something that already works?

  • Curiosity — the fields you see in Wireshark become structs you wrote
  • Debugging — timeouts and resets stop being mysteries
  • Vocabulary — cloud networking, eBPF and QUIC all assume these layers
  • Joy — watching ping get answered by your code

Not competing with the kernel. Learning beside it.

What this project is, and isn’t

It is

  • A teaching network stack, run on a Linux TAP device
  • Built in stages, each ending in a demo
  • Honest about what’s missing

It isn’t

  • Production-grade TCP
  • Flow control or congestion control
  • A replacement for the kernel

Keeping the scope small is how it got finished.

What you’ll leave with

  1. Why — a reason to take apart something that already works
  2. Scope — a teaching stack, not production TCP
  3. The onion — every layer has the same shape
  4. A demo — from ping to HTTPS, answered by our code
  5. Giants — great systems are built from many people’s work
  6. Foresight — protocols that shipped and left room for what came later
  7. Where to dig — each layer is a whole field of its own

The onion interface

Every layer has the same shape, even without a shared Go interface type.

What “onion interface” means

Each layer is a header plus a payload.
In code it’s a struct with Decode (bytes → struct) and Encode (struct → bytes).
It supports only a few operations.
Its payload is the next layer’s whole message.

Layers nest inside each other. A type field in each header says which layer comes next.

Each layer is an RFC. Its payload is the next one.

LayerDocumentWhat the payload can be
EthernetRFC 894IPv4, or ARP (RFC 826). The type field decides.
IPv4RFC 791TCP, or ICMP (RFC 792). The protocol field decides.
TCPRFC 9293A byte stream: HTTP (RFC 9112), TLS (RFC 5246), or a protocol nobody has written yet.
TLS 1.2RFC 5246HTTP again, now encrypted. Or something else that fits in a record.
HTTP/1.1RFC 9112A message. The body can carry yet another protocol.

A new layer is an agreement about how to read someone else’s payload. The layers outside do not change.

The lab setup

ping / curl
↓
kernel IP stack
↓
tap0 · 10.0.0.1/24
↕ Ethernet frames
mytcp · 10.0.0.2

A TAP device hands us whole Ethernet frames, so we handle ARP ourselves. It runs in a Linux container, because macOS has no TAP.

One layer at a time

From the bottom up, with a demo at each step.

The stack we’re going to build

APPLICATION( )
SECURITY optional( )
CONTROL( )
TRANSPORT( )
NETWORK( )
LINK( )
WIRE( )

Every layer does the same job: read bytes, decide, write bytes.

Bottom layer: the wire

APPLICATION( )
SECURITY optional( )
CONTROL( )
TRANSPORT( )
NETWORK( )
LINK( )
WIRELinux TAP device

TAP hands us every Ethernet frame sent to 10.0.0.2, raw.

Opening a TAP device

internal/tap/tap_linux.go

// Linux creates TUN and TAP devices through one special file
f, err := os.OpenFile("/dev/net/tun", os.O_RDWR, 0)

// ask for a TAP device (whole Ethernet frames) named e.g. "tap0"
var req ifreq
req.flags = iffTap | iffNoPI   // no extra packet-info header
copy(req.name[:], name)
syscall.Syscall(SYS_IOCTL, fd, tunSetIff, …)

// from now on, each read returns one frame and each write sends one

Rabbit holes kernel networking internals · virtual NICs (veth, TAP, macvlan) · XDP / eBPF · NIC offloads

Next: the link layer

APPLICATION( )
SECURITY optional( )
CONTROL( )
TRANSPORT( )
NETWORK( )
LINKEthernet + ARP
WIRELinux TAP device

Ethernet: machines on the same network talk using MAC addresses.

An Ethernet frame, as a Go struct

internal/eth/ethernet.go

// One Ethernet frame: a 14-byte header, then the payload
type Frame struct {
    Dst     net.HardwareAddr // who it's for (6-byte MAC address)
    Src     net.HardwareAddr // who sent it
    Type    uint16           // what's inside: 0x0806 ARP, 0x0800 IPv4
    Payload []byte           // ← the next layer's whole message
}

// bytes off the wire → struct
func Decode(b []byte) (Frame, error) { /* … */ }

// struct → bytes ready to send
func (f Frame) Encode() []byte     { /* … */ }

Addresses, a type field saying what’s inside, and the payload. Every layer follows this pattern.

Decoding means reading bytes at fixed offsets

internal/eth/ethernet.go

func Decode(b []byte) (Frame, error) {
    // anything shorter than the 14-byte header can't be a frame
    if len(b) < HeaderLen {
        return Frame{}, fmt.Errorf("ethernet: frame too short")
    }
    // every field sits at a fixed position, so just copy it out
    return Frame{
        Dst:     append(net.HardwareAddr(nil), b[0:6]...),  // bytes 0–5
        Src:     append(net.HardwareAddr(nil), b[6:12]...), // bytes 6–11
        Type:    binary.BigEndian.Uint16(b[12:14]),         // bytes 12–13
        Payload: append([]byte(nil), b[14:]...),            // the rest
    }, nil
}

An Ethernet frame, as real bytes

captures/talk-http.jsonl · frame 0 (an ARP request)

# mytcp logs every frame to captures/<name>.jsonl (and a .pcap for Wireshark)
# pick frame 0, turn its hex back into bytes, and dump them
jq -r 'select(.n==0).data' captures/talk-http.jsonl | xxd -r -p | hexdump -C
00000000  ff ff ff ff ff ff ee ea  f3 0f 57 32 08 06 00 01  |..........W2....|
00000010  08 00 06 04 00 01 ee ea  f3 0f 57 32 0a 00 00 01  |..........W2....|
00000020  00 00 00 00 00 00 0a 00  00 02                    |..........|
0000002a
BytesFieldValue
0–5Destination MAC (Dst)ff:ff:ff:ff:ff:ff = broadcast: everyone on the link
6–11Source MAC (Src)ee:ea:f3:0f:57:32 = the kernel’s side of tap0
12–13EtherType (Type)08 06 = 0x0806 = ARP, so arp.Decode gets the rest
14–41Payload (Payload)28 bytes: the ARP message, unpacked in the ARP section

Each line: where it starts in hex (00000010 = byte 16), 16 bytes in hex, then the same bytes as text (. = not printable).

Numbers longer than a byte are big-endian, biggest byte first (“network byte order”): 08 06 is 0x0806. Go reads them with binary.BigEndian.Uint16.

Choosing the next layer by its type field

internal/stack/stack.go · HandleFrame

f, err := eth.Decode(b)
// …

// the EtherType field says what the payload is
switch f.Type {
case eth.TypeARP:  // 0x0806: "who has this IP?"
    return s.handleARP(f)
case eth.TypeIPv4: // 0x0800: an IP packet, for later
    return s.handleIPv4(f)
default:           // usually IPv6 chatter from the host: drop it
    return nil
}

Each protocol only decodes its own header. The stack package reads the type field and hands the payload to the right decoder. Frame 0 said 08 06, so next: ARP.

An ARP packet, as a Go struct

internal/arp/arp.go

// "Who has this IP?" (request) or "I do, here's my MAC" (reply)
type Packet struct {
    Op  uint16           // 1 = request, 2 = reply
    SHA net.HardwareAddr // sender MAC
    SPA net.IP           // sender IP
    THA net.HardwareAddr // target MAC (blank in a request)
    TPA net.IP           // target IP: the one being asked about
}

func Decode(b []byte) (Packet, error) { /* … */ } // bytes → struct
func (p Packet) Encode() []byte     { /* … */ } // struct → bytes

ARP: ask the local network which MAC address owns this IP. The owner replies with its MAC.

Answering “who has 10.0.0.2?”

internal/stack/stack.go · handleARP

p, err := arp.Decode(f.Payload)
// ignore replies, and questions about someone else's IP
if p.Op != arp.OpRequest || !p.TPA.Equal(s.cfg.IP) {
    return nil
}
// build the answer: "10.0.0.2 is at <our MAC>"
reply := arp.ReplyFor(p, s.cfg.MAC, s.cfg.IP)
// wrap it in Ethernet, addressed straight back to whoever asked
out := eth.Frame{
    Dst: p.SHA, Src: s.cfg.MAC, Type: eth.TypeARP,
    Payload: reply.Encode(),
}
return s.writeFrame(out)

Rabbit holes switching and MAC learning · VLANs · spanning tree · ARP spoofing · the Wi-Fi MAC layer

A real ARP request and reply: the header

captures/talk-http.jsonl · the first two frames of the capture

Frame 0 · kernel → everyone · the question: “who has 10.0.0.2?”

00000000  ff ff ff ff ff ff ee ea  f3 0f 57 32 08 06 00 01  |..........W2....|
00000010  08 00 06 04 00 01 ee ea  f3 0f 57 32 0a 00 00 01  |..........W2....|
00000020  00 00 00 00 00 00 0a 00  00 02                    |..........|
0000002a

Frame 1 · mytcp → kernel · our answer: “10.0.0.2 is at 02:00:00:00:00:02”

00000000  ee ea f3 0f 57 32 02 00  00 00 00 02 08 06 00 01  |....W2..........|
00000010  08 00 06 04 00 02 02 00  00 00 00 02 0a 00 00 02  |................|
00000020  ee ea f3 0f 57 32 0a 00  00 01                    |....W2....|
0000002a
BytesFieldRequest (frame 0)Reply (frame 1)
0–13Ethernet headerto ff:ff:ff:ff:ff:ff (everyone), from ee:ea:f3:0f:57:32to ee:ea:f3:0f:57:32 (only the asker), from 02:00:00:00:00:02
14–15Hardware type00 01 = Ethernetsame
16–17Protocol type08 00 = IPv4same
18Hardware address length06 = a MAC is 6 bytessame
19Protocol address length04 = an IPv4 address is 4 bytessame
20–21Operation (Op)00 01 = request00 02 = reply
22–41AddressesSHA, SPA, THA, TPA — next slidesame four fields, filled in

One packet format for both directions: the header fields match byte for byte, except the operation.

A real ARP request and reply: the addresses

captures/talk-http.jsonl · the first two frames of the capture

Frame 0 · kernel → everyone · the question: “who has 10.0.0.2?”

00000000  ff ff ff ff ff ff ee ea  f3 0f 57 32 08 06 00 01  |..........W2....|
00000010  08 00 06 04 00 01 ee ea  f3 0f 57 32 0a 00 00 01  |..........W2....|
00000020  00 00 00 00 00 00 0a 00  00 02                    |..........|
0000002a

Frame 1 · mytcp → kernel · our answer: “10.0.0.2 is at 02:00:00:00:00:02”

00000000  ee ea f3 0f 57 32 02 00  00 00 00 02 08 06 00 01  |....W2..........|
00000010  08 00 06 04 00 02 02 00  00 00 00 02 0a 00 00 02  |................|
00000020  ee ea f3 0f 57 32 0a 00  00 01                    |....W2....|
0000002a
BytesFieldRequest (frame 0)Reply (frame 1)
0–21Ethernet + ARP headerbroadcast request · Op = 1unicast reply · Op = 2
22–27Sender MAC (SHA)ee:ea:f3:0f:57:3202:00:00:00:00:02 ← the answer
28–31Sender IP (SPA)0a 00 00 01 = 10.0.0.10a 00 00 02 = 10.0.0.2
32–37Target MAC (THA)00:00:00:00:00:00 = unknown (the question)ee:ea:f3:0f:57:32
38–41Target IP (TPA)0a 00 00 02 = 10.0.0.20a 00 00 01 = 10.0.0.1

The reply swaps sender and target, and fills in the one thing the request didn’t know: our MAC.

Next: the network layer

APPLICATION( )
SECURITY optional( )
CONTROL( )
TRANSPORT( )
NETWORKIPv4
LINKEthernet + ARP
WIRELinux TAP device

IPv4: addresses that work across networks, so routers can carry a packet hop by hop.

An IPv4 packet, as a Go struct

internal/ip4/ipv4.go

// one hop-by-hop packet: where from, where to, and what's inside
type Packet struct {
    TOS       uint8  // type of service; 0 means ordinary traffic
    ID        uint16 // identification, used to reassemble fragments
    FlagsFrag uint16 // flags (e.g. Don't Fragment) + fragment offset
    TTL       uint8  // hop limit: every router takes one off
    Proto     uint8  // what's in Payload: 1 = ICMP, 6 = TCP
    Src       net.IP // source address
    Dst       net.IP // destination address
    Payload   []byte // the ICMP or TCP message
}

func Decode(b []byte) (Packet, error) { /* … */ } // bytes → struct
func (p Packet) Encode() []byte     { /* … */ } // struct → bytes

MAC addresses only reach the next machine. An IP address names the final destination, so the packet keeps it the whole way while the Ethernet header is rewritten at every hop.

An IPv4 header, on a real packet

captures/talk-http.jsonl · frame 2 (from ping -c 1 10.0.0.2)

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 54 c6 d3 40 00 40 01  5f d3 0a 00 00 01 0a 00  |.T..@.@._.......|
00000020  00 02 08 00 40 fe 00 07  00 01 80 39 bc 6a 00 00  |....@......9.j..|
00000030  00 00 b9 82 02 00 00 00  00 00 10 11 12 13 14 15  |................|
00000040  16 17 18 19 1a 1b 1c 1d  1e 1f 20 21 22 23 24 25  |.......... !"#$%|
00000050  26 27 28 29 2a 2b 2c 2d  2e 2f 30 31 32 33 34 35  |&'()*+,-./012345|
00000060  36 37                                             |67|
00000062
BytesFieldValue
0–13Ethernet headertype 08 00 = IPv4
14Version + header length45: 4 = IPv4, 5 × 4 = 20-byte header
15Traffic class (DSCP/ECN)00 = ordinary traffic
16–17Total length00 54 = 84 bytes: this header plus the ICMP message
18–19Identificationc6 d3: only used if the packet gets split up
20–21Flags + fragment offset40 00 = “don’t fragment”, and this isn’t a fragment
22Time to live40 = 64 hops before a router throws it away
23Protocol (Proto)01 = ICMP, so icmp.Decode gets the payload
24–25Header checksum5f d3: covers these 20 bytes only
26–29Source IP (Src)0a 00 00 01 = 10.0.0.1
30–33Destination IP (Dst)0a 00 00 02 = 10.0.0.2
34–97ICMP messagean echo request, unpacked in the ICMP section

IPv4’s protocol field picks the next layer

internal/stack/stack.go · handleIPv4

// after ip4.Decode, the protocol field says what the payload is
switch pkt.Proto {
case ip4.ProtoICMP: // 1 = ICMP, e.g. ping
    return s.handleICMP(f.Src, pkt)
case ip4.ProtoTCP:  // 6 = TCP: peel one more layer
    seg, err := tcp.Decode(pkt.Payload)
    // …
    return s.tcp.Handle(f.Src, pkt.Src, seg)
}

ICMP and TCP both ride on IPv4, side by side. The ping said 01, so ICMP first.

Rabbit holes routing (OSPF, BGP) · IPv6 · NAT · MTU and fragmentation

On top of IPv4: ICMP

APPLICATION( )
SECURITY optional( )
CONTROLICMP
TRANSPORT( )
NETWORKIPv4
LINKEthernet + ARP
WIRELinux TAP device

ICMP is the network’s own messages. Ping asks “can a packet get there and back?”, which proves the IP path works before we trust it with TCP.

Answering a ping

internal/stack/stack.go · handleICMP

// the IPv4 payload is an ICMP message
echo, err := icmp.Decode(pkt.Payload)
// only answer echo requests (type 8)
if echo.Type != icmp.TypeEcho {
    return nil
}
// same ID, sequence number and data, but type 0 = echo reply
reply := icmp.ReplyFrom(echo)
// Encode adds a fresh checksum; send it back in IPv4, then Ethernet
return s.sendIPv4(srcMAC, pkt.Src, ip4.ProtoICMP, reply.Encode())

Rabbit holes how traceroute uses TTL and “time exceeded” · “destination unreachable” · path MTU discovery · why some firewalls drop ping

A real ping, there and back

captures/talk-http.jsonl · frames 2 and 3

Frame 2 · kernel → mytcp · ping asks: “are you there?”

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 54 c6 d3 40 00 40 01  5f d3 0a 00 00 01 0a 00  |.T..@.@._.......|
00000020  00 02 08 00 40 fe 00 07  00 01 80 39 bc 6a 00 00  |....@......9.j..|
00000030  00 00 b9 82 02 00 00 00  00 00 10 11 12 13 14 15  |................|
00000040  16 17 18 19 1a 1b 1c 1d  1e 1f 20 21 22 23 24 25  |.......... !"#$%|
00000050  26 27 28 29 2a 2b 2c 2d  2e 2f 30 31 32 33 34 35  |&'()*+,-./012345|
00000060  36 37                                             |67|
00000062

Frame 3 · mytcp → kernel · our echo reply

00000000  ee ea f3 0f 57 32 02 00  00 00 00 02 08 00 45 00  |....W2........E.|
00000010  00 54 00 01 00 00 40 01  66 a6 0a 00 00 02 0a 00  |.T....@.f.......|
00000020  00 01 00 00 48 fe 00 07  00 01 80 39 bc 6a 00 00  |....H......9.j..|
00000030  00 00 b9 82 02 00 00 00  00 00 10 11 12 13 14 15  |................|
00000040  16 17 18 19 1a 1b 1c 1d  1e 1f 20 21 22 23 24 25  |.......... !"#$%|
00000050  26 27 28 29 2a 2b 2c 2d  2e 2f 30 31 32 33 34 35  |&'()*+,-./012345|
00000060  36 37                                             |67|
00000062
BytesFieldRequest (frame 2)Reply (frame 3)
0–33Ethernet + IPv4 headers10.0.0.1 → 10.0.0.2 · proto 01 = ICMP10.0.0.2 → 10.0.0.1: addresses swapped
34Type (Type)08 = echo request00 = echo reply ← the change that matters
35Code00: echo has no sub-typessame
36–37Checksum40 fe: covers the whole ICMP message48 fe: redone, because the type changed
38–39Identifier (ID)00 07: which ping process sent itsame, so ping knows the reply is its own
40–41Sequence number (Seq)00 01 = the first pingsame
42–57Data: send time80 39 bc 6a …: when ping sent itcopied back, so ping can time the round trip
58–97Data: filler10 11 12 … 37: a counting patterncopied back

The reply is the request sent back with its type changed, the addresses swapped and the checksums redone.

Next: the transport layer

APPLICATION( )
SECURITY optional( )
CONTROLICMP
TRANSPORTTCP
NETWORKIPv4
LINKEthernet + ARP
WIRELinux TAP device

TCP sits on IPv4 next to ICMP. It turns lost and reordered packets into a reliable, ordered stream between two programs. Ours connects, sends, receives and closes; no congestion control.

A TCP segment, as a Go struct

internal/tcp/segment.go

type Segment struct {
    SrcPort, DstPort uint16 // which app on each end (a number 1–65535)
    Seq, Ack         uint32 // "my bytes start at N", "I got yours up to M"
    Flags            uint8  // SYN (open), ACK, FIN (close), RST (abort)
    Window           uint16 // how many more bytes the sender can take
    Payload          []byte // app bytes, if any; a SYN has none
}

func Decode(b []byte) (Segment, error) { /* … */ }
// the checksum also covers both IP addresses, so Encode needs them
func (s Segment) Encode(srcIP, dstIP net.IP) []byte { /* … */ }

Rabbit holes congestion control (Reno, CUBIC, BBR) · flow control and windows · SACK · QUIC · SRD

Three messages to open, then bytes can flow

%%{init: {"sequence": {"messageMargin": 20, "height": 40, "actorMargin": 300}}}%% sequenceDiagram participant C as curl participant S as mytcp C->>S: SYN · seq 333994598 S->>C: SYN+ACK · seq 1000 · ack 333994599 C->>S: ACK · ack 1001 · open C->>S: GET / · 72 bytes · seq 333994599 S->>C: 200 OK · 233 bytes · seq 1001 · ack 333994671 S->>C: FIN · seq 1234 · HTTP/1.0 closes after one reply C->>S: ACK · ack 1234 (= 1001 + 233: got the reply) C->>S: FIN · seq 333994671 S->>C: ACK · ack 333994672 C->>S: ACK · ack 1235 (got our FIN)

An ack means “I have every byte before this one”. SYN and FIN each count as one.

A real TCP segment: opening a connection

captures/talk-http.jsonl · frame 4 · client → mytcp

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 3c 56 ea 40 00 40 06  cf cf 0a 00 00 01 0a 00  |.<V.@.@.........|
00000020  00 02 c6 8a 00 50 13 e8  5a 66 00 00 00 00 a0 02  |.....P..Zf......|
00000030  fa f0 2f 00 00 00 02 04  05 b4 04 02 08 0a a1 a8  |../.............|
00000040  33 3b 00 00 00 00 01 03  03 07                    |3;........|
0000004a
BytesFieldValue
0–33Ethernet + IPv4 headersas in the ping · IPv4 protocol 06 = TCP
34–35Source port (SrcPort)c6 8a = 50826: a random free port the client’s kernel picked
36–37Destination port (DstPort)00 50 = 80: the port our stack is listening on
38–41Sequence number (Seq)13 e8 5a 66 = 333994598: the client’s starting number, chosen to be hard to guess
42–45Acknowledgment (Ack)00 00 00 00: nothing to acknowledge yet
46Header lengtha0: top 4 bits, a = 10 × 4 = 40 bytes (20 fixed + 20 of options)
47Flags (Flags)02 = SYN: “let’s talk; my bytes will be numbered from here”
48–49Window (Window)fa f0 = 64240 bytes the client can take before we must wait
50–51Checksum2f 00: covers the TCP header and data, plus both IPs
52–53Urgent pointer00 00: unused (almost nobody uses it)
54–73Options20 bytes, on the next slide · no payload: the application has not spoken yet

No application data. A SYN only opens the connection; bytes for an app come later, after both sides agree.

The SYN’s options, and why we ignore them

captures/talk-http.jsonl · frame 4 · same SYN

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 3c 56 ea 40 00 40 06  cf cf 0a 00 00 01 0a 00  |.<V.@.@.........|
00000020  00 02 c6 8a 00 50 13 e8  5a 66 00 00 00 00 a0 02  |.....P..Zf......|
00000030  fa f0 2f 00 00 00 02 04  05 b4 04 02 08 0a a1 a8  |../.............|
00000040  33 3b 00 00 00 00 01 03  03 07                    |3;........|
0000004a
BytesOptionBytes on the wire
0–53Ethernet, IPv4, TCP headerthe previous slide
54–57Max segment size (MSS)02 04 05 b4: kind 2, length 4, 1460 = “send me at most 1460 bytes per segment”
58–59SACK permitted04 02: “I can say exactly which pieces I’m missing”
60–69Timestamps08 0a + curl’s clock a1 a8 33 3b + 00 00 00 00 (no echo yet): helps measure round trips
70No-op01: padding, so the next option lines up on 4 bytes
71–73Window scale03 03 07: “multiply my window by 27 = 128”, for windows over 64 KB

Options are offers. Our SYN+ACK sends none back, so the kernel on curl’s side turns off SACK, timestamps and window scaling, and falls back to the default 536-byte segments. Everything still works, just less efficiently.

The three-way handshake, in bytes

captures/talk-http.jsonl · frames 4, 5 and 6

Frame 4 · curl → mytcp · SYN

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 3c 56 ea 40 00 40 06  cf cf 0a 00 00 01 0a 00  |.<V.@.@.........|
00000020  00 02 c6 8a 00 50 13 e8  5a 66 00 00 00 00 a0 02  |.....P..Zf......|
00000030  fa f0 2f 00 00 00 02 04  05 b4 04 02 08 0a a1 a8  |../.............|
00000040  33 3b 00 00 00 00 01 03  03 07                    |3;........|
0000004a

Frame 5 · mytcp → curl · SYN+ACK

00000000  ee ea f3 0f 57 32 02 00  00 00 00 02 08 00 45 00  |....W2........E.|
00000010  00 28 00 02 00 00 40 06  66 cc 0a 00 00 02 0a 00  |.(....@.f.......|
00000020  00 01 00 50 c6 8a 00 00  03 e8 13 e8 5a 67 50 12  |...P........ZgP.|
00000030  ff ff 62 be 00 00                                 |..b...|
00000036

Frame 6 · curl → mytcp · ACK

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 28 56 eb 40 00 40 06  cf e2 0a 00 00 01 0a 00  |.(V.@.@.........|
00000020  00 02 c6 8a 00 50 13 e8  5a 67 00 00 03 e9 50 10  |.....P..Zg....P.|
00000030  fa f0 67 ce 00 00                                 |..g...|
00000036

Ethernet IPv4 TCP ports Seq Ack header length + flags window, checksum, urgent options (SYN only)

FrameDirectionLength + flags (bytes 46–47)Seq (bytes 38–41)Ack (bytes 42–45)
4curl → mytcpa0 02 · SYN, 40-byte header13 e8 5a 66 = 333994598 (curl’s start)0
5mytcp → curl50 12 · SYN+ACK, 20-byte header00 00 03 e8 = 1000 (ours; fixed so captures read easily)333994599 = theirs + 1
6curl → mytcp50 10 · ACK, 20-byte header33399459900 00 03 e9 = 1001 = ours + 1

The first segment that carries data

captures/talk-http.jsonl · frame 7 · same connection, next segment from the client

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 70 56 ec 40 00 40 06  cf 99 0a 00 00 01 0a 00  |.pV.@.@.........|
00000020  00 02 c6 8a 00 50 13 e8  5a 67 00 00 03 e9 50 18  |.....P..Zg....P.|
00000030  fa f0 91 b1 00 00 47 45  54 20 2f 20 48 54 54 50  |......GET / HTTP|
00000040  2f 31 2e 31 0d 0a 48 6f  73 74 3a 20 31 30 2e 30  |/1.1..Host: 10.0|
00000050  2e 30 2e 32 0d 0a 55 73  65 72 2d 41 67 65 6e 74  |.0.2..User-Agent|
00000060  3a 20 63 75 72 6c 2f 37  2e 38 38 2e 31 0d 0a 41  |: curl/7.88.1..A|
00000070  63 63 65 70 74 3a 20 2a  2f 2a 0d 0a 0d 0a        |ccept: */*....|
0000007e
BytesFieldValue
0–13Ethernet headersame link as the handshake
14–33IPv4 headerprotocol 06 = TCP · 112 bytes in total
34–53TCP header20 bytes, no options · 50 18 = PSH + ACK · seq is the client’s start + 1 · ack 1001
54–125Payload72 bytes the application sent. TCP numbers them and delivers them in order. It does not read them.

The letters in the margin are the application’s. TCP does not know what they mean.

TCP is a stream. Segment edges mean nothing upstairs.

What it needs from TCPWhy
HTTPBytes, until it finds \r\n\r\nFraming is in the text. A push of chunks is enough.
TLSBlocking Read / WriteThe handshake waits for whole records, on its own schedule.

Give the app a net.Conn and a Listener, and both fit: HTTP reads until the blank line, TLS drives the handshake itself. Our server, Go’s net/http, and TLS all plug in the same way.

From segments to sockets: four types

internal/tcp

Segment     one packet: ports, seq, ack, flags, payload       segment.go
   │  Stack.Handle looks up its connection by (their IP, their port, our port)
   ▼
Conn        one connection's TCP state, inside the stack      stack.go
            state SYN_RECEIVED → ESTABLISHED, next seq / ack,
            sent-but-unacked segments to resend
   │  reaches ESTABLISHED: wrapped once
   ▼
StreamConn  the app's view of that Conn: Read, Write, Close   stream.go
   │  handed out by
   ▼
Listener    one per port: Accept returns the next StreamConn  listener.go

Segments carry bytes; Conn puts them in order; StreamConn lets an app read them; Listener is how the app gets one. Going out, it runs the other way: Write → Conn → new Segments.

One connection’s state: Conn

internal/tcp/stack.go

type Stack struct {
    listen map[uint16]struct{} // ports in LISTEN
    conns  map[fourTuple]*Conn // (their IP, their port, our port) → Conn
}

type Conn struct {
    tuple          fourTuple
    state          State         // SYN_RECEIVED, ESTABLISHED, FIN_WAIT_1, …
    rcvNxt         uint32        // next seq we expect; goes in our Ack field
    sndUna, sndNxt uint32        // oldest unacked byte; next byte we send
    outq           []outstanding // sent Segments waiting for an ACK
    stream         *StreamConn   // the app's view; set at ESTABLISHED
}

RFC 9293 calls this the Transmission Control Block. The app never touches it.

Every Segment goes through its Conn

internal/tcp/stack.go

func (s *Stack) Handle(from net.IP, seg Segment) {
    c, ok := s.conns[fourTuple{from, seg.SrcPort, seg.DstPort}]
    if !ok { /* SYN to a listen port → new Conn in SYN_RECEIVED */ }
    switch c.state {
    case StateSynReceived: // handshake ACK
        c.state = StateEstablished
        c.stream = newStreamConn(s, c)
        go s.listen[seg.DstPort].push(c.stream) // → Accept, once
    case StateEstablished:
        if seg.Seq == c.rcvNxt { c.stream.deliver(seg.Payload) } // → Read
    }
}

Simplified: the real code also advances rcvNxt, frees acked outq entries, drops out-of-order segments with a re-ACK, and runs the FIN and RST paths.

A TCP connection, as a Go net.Conn

internal/tcp/stream.go

type StreamConn struct {
    tuple   fourTuple // which Conn in the stack this is the view of
    rbuf    []byte    // payloads from in-order Segments, not yet Read
    rClosed bool      // a FIN Segment arrived: Read → EOF once rbuf is empty
}

func (c *StreamConn) Read(p []byte) (int, error) {
    for len(c.rbuf) == 0 && !c.rClosed {
        c.cond.Wait() // block until the stack delivers more
    }
    // … copy out of rbuf
}

func (c *StreamConn) Write(p []byte) (int, error) { /* → segments */ }
func (c *StreamConn) Close() error                 { /* → FIN */ }

In: each in-order Segment’s payload is appended to rbuf and Read wakes. Out: Write asks the Conn for PSH+ACK Segments. The app never sees a segment.

A listen port, as a Go net.Listener

internal/tcp/listener.go

type Listener struct {
    ch   chan *StreamConn // ESTABLISHED conns waiting for Accept
    done chan struct{}
}

func (s *Stack) Listen(port uint16) *Listener { /* s.listen[port] = ln */ }

// a Conn just reached ESTABLISHED: the stack pushes, the app pulls
func (l *Listener) push(c *StreamConn) { l.ch <- c }
func (l *Listener) Accept() (net.Conn, error) {
    select {
    case c := <-l.ch: return c, nil
    case <-l.done:   return nil, net.ErrClosed
    }
}

Compile-time checks: both types satisfy Go’s socket interfaces. HTTPS and http.Server.Serve(ln) need no special casing.

Now the other direction: Dial

internal/tcp/dial.go · Dial (trimmed)

func (s *Stack) Dial(ctx context.Context, ip net.IP, port uint16) (*StreamConn, error) {
    mac, err := s.emit.Resolve(ctx, ip) // our own ARP: who has the next hop?

    s.mu.Lock()
    c := &Conn{state: StateSynSent, remoteMAC: mac, localPort: 49152 /* next free */}
    s.conns[c.tuple] = c
    s.sendCtrlLocked(c, FlagSYN, nil)   // step one, retransmitted like any SYN
    s.mu.Unlock()

    select {
    case err := <-c.dialDone:          // Handle: SYN+ACK in, ACK out, ESTABLISHED
        return c.stream, err           // or ECONNREFUSED, if a RST came back
    case <-ctx.Done():
        return nil, ctx.Err()
    }
}

One new state, SYN_SENT. What comes back is the same StreamConn that Accept returns.

AWS replaced TCP underneath: SRD

Apps still open TCP sockets. Underneath, AWS can carry the traffic with its own protocol.

  • SRD (Scalable Reliable Datagram) runs on AWS’s Nitro network card
  • Sprays one flow across many network paths
  • Resends lost packets within microseconds, in hardware
  • Delivers everything, but out of order; the layer above puts it back in order

TCP isn’t mandatory everywhere. Neither is TLS.

Where SRD sits under your TCP connection

your app     ── TCP socket          (API unchanged)
                     │
Nitro card   ── SRD                 multipath · fast retransmit
                     │              reliable, out-of-order
fabric       ── many paths at once
                     │
peer Nitro   ── reorder → hand TCP bytes up

With ENA Express, ordinary TCP traffic between instances rides on SRD without the app noticing. Because the layers are separate, AWS could replace the middle and leave both ends alone.

Next: the application

APPLICATIONHTTP/1 on net.Listener
SECURITY optional( )
CONTROLICMP
TRANSPORTTCP
NETWORKIPv4
LINKEthernet + ARP
WIRELinux TAP device

HTTP: the application layer, which gives the stream meaning: a request and a response.

Our own HTTP server: bytes in, bytes out

internal/http1/server.go (trimmed) · -app http

func (s *Server) ServeConn(c net.Conn) { // from our Listener’s Accept
    defer c.Close()                       // Close → our FIN
    req, buf := []byte(nil), make([]byte, 4096)
    for {
        n, err := c.Read(buf)             // whatever TCP has so far
        req = append(req, buf[:n]...)
        if end := bytes.Index(req, []byte("\r\n\r\n")); end >= 0 {
            c.Write(s.respond(string(req[:end]))) // headers done: answer
            return
        }
        if err != nil { return }
    }
}
func (s *Server) respond(head string) []byte { // "GET / HTTP/1.1\r\nHost: …"
    if !strings.HasPrefix(head, "GET ") { return s.response(405, …) }
    return []byte("HTTP/1.0 200 OK\r\nContent-Length: " + strconv.Itoa(len(body)) +
        "\r\nConnection: close\r\n\r\n" + body)
}

Or just hand the Listener to Go

internal/http1/server.go · Handler (trimmed) · cmd/mytcp/main.go · -app http-go

page := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet && r.Method != http.MethodHead {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.Header().Set("Connection", "close")
    io.WriteString(w, body) // Go writes the status line and the rest
})

srv := &http.Server{Handler: page}
srv.SetKeepAlivesEnabled(false)   // like ours: one request per connection
srv.Serve(stack.TCP().Listen(80)) // Accept, decode, respond, close: all Go

Go decodes the request into r and gives us w to answer. curl can’t tell the two servers apart.

Our http1 vs Go’s net/http

http1net/http
Decodesthe request line onlyrequest line, headers, body, chunked
Per connectionone request, then FINkeep-alive: many requests
Limits64 KiB of headerstimeouts, header size, body size
You writethe bytes on the wirea Handler, given w and r
Size~210 lines~29,000 lines (client and server)
Runs onnet.Listenernet.Listener

Ours shows what HTTP/1 is: text on a stream. Go’s is the one you’d ship.

Go’s http.Client, on our TCP

cmd/mytcp/main.go · runGet (trimmed) · -get URL

hc := &http.Client{
    Transport: &http.Transport{
        DialContext: st.DialContext, // net.Dialer's signature, our Dial inside
    },
}

resp, err := hc.Get("https://www.google.com/")
// DNS: ours (UDP to 1.1.1.1) · TCP: ours · TLS and HTTP: Go's

Through the lab’s NAT this fetches google.com’s 84 KB home page. Same net.Conn, opened from our end.

DNS without the kernel

internal/stack/dial.go · LookupIPv4 (trimmed) · internal/dns · internal/udp

func (s *Stack) LookupIPv4(ctx context.Context, host string) (net.IP, error) {
    q, _ := dns.Query(id, host) // header, 3www6google3com0, type A, class IN
    u, _ := s.BindUDP(0)        // our UDP, an ephemeral port
    defer u.Close()

    for try := 0; try < 3; try++ {
        u.WriteTo(ctx, q, s.cfg.DNS, 53)           // ARP the gateway, out via NAT
        b, ip, port, err := u.ReadFrom(within1s)   // only 1.1.1.1:53 counts
        if err == nil && ip.Equal(s.cfg.DNS) && port == 53 {
            return first(dns.ParseResponse(b, id)) // skip CNAMEs, first A record
        }
    }
    return nil, errNoAnswer
}

A stub resolver, like libc’s: one UDP question to a server that does the real work, one answer back.

Same question to strace, this time for mytcp

strace -f -e trace=%network mytcp -get https://www.google.com/ · inside Docker

socket(AF_NETLINK, SOCK_RAW|SOCK_CLOEXEC, NETLINK_ROUTE) = 3
sendto(3, [{nlmsg_type=RTM_NEWLINK, ...}], 32, 0, ...) = 32
# ↑ the ip tool bringing tap0 up. Setup, not traffic.

# No socket(AF_INET). No connect. No sendto(5, "GET / …").
# ARP, DNS, TCP, TLS and HTTP all left as read and write
# on /dev/net/tun.

curl fetching the same page: 41 socket and connect lines. mytcp: none. The kernel is just the wire.

One HTTP request, every layer at once

captures/talk-http.jsonl · frame 7 · curl → mytcp, after the handshake

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 70 56 ec 40 00 40 06  cf 99 0a 00 00 01 0a 00  |.pV.@.@.........|
00000020  00 02 c6 8a 00 50 13 e8  5a 67 00 00 03 e9 50 18  |.....P..Zg....P.|
00000030  fa f0 91 b1 00 00 47 45  54 20 2f 20 48 54 54 50  |......GET / HTTP|
00000040  2f 31 2e 31 0d 0a 48 6f  73 74 3a 20 31 30 2e 30  |/1.1..Host: 10.0|
00000050  2e 30 2e 32 0d 0a 55 73  65 72 2d 41 67 65 6e 74  |.0.2..User-Agent|
00000060  3a 20 63 75 72 6c 2f 37  2e 38 38 2e 31 0d 0a 41  |: curl/7.88.1..A|
00000070  63 63 65 70 74 3a 20 2a  2f 2a 0d 0a 0d 0a        |ccept: */*....|
0000007e
BytesFieldValue
0–13Ethernet headertype 08 00 = IPv4
14–33IPv4 headersame fields as the ping · length 00 70 = 112 · protocol 06 = TCP
34–37Ports (SrcPort → DstPort)c6 8a → 00 50 = 50826 → 80, same as the SYN
38–41Sequence number (Seq)13 e8 5a 67 = the SYN’s start + 1: the first byte of this payload
42–45Acknowledgment (Ack)00 00 03 e9 = 1001: “got everything before 1001” (we started at 1000)
46–47Header length + flags (Flags)50 18: 5 × 4 = 20-byte header · 0x18 = PSH + ACK
48–49Window (Window)fa f0 = 64240 more bytes curl can take
50–51Checksum91 b1: covers TCP header, payload and both IPs
52–53Urgent pointer00 00: unused
54–125Payload (Payload)72 bytes of HTTP: GET / HTTP/1.1, Host, User-Agent, Accept, blank line

On the way in, mytcp peels Ethernet, IPv4 and TCP in turn. What’s left, the green part, is all the HTTP code sees.

The response, wrapped on the way out

captures/talk-http.jsonl · frame 8 · mytcp → curl · first 153 of 287 bytes

00000000  ee ea f3 0f 57 32 02 00  00 00 00 02 08 00 45 00  |....W2........E.|
00000010  01 11 00 03 00 00 40 06  65 e2 0a 00 00 02 0a 00  |......@.e.......|
00000020  00 01 00 50 c6 8a 00 00  03 e9 13 e8 5a af 50 18  |...P........Z.P.|
00000030  ff ff 5c f6 00 00 48 54  54 50 2f 31 2e 30 20 32  |..\...HTTP/1.0 2|
00000040  30 30 20 4f 4b 0d 0a 43  6f 6e 74 65 6e 74 2d 54  |00 OK..Content-T|
00000050  79 70 65 3a 20 74 65 78  74 2f 68 74 6d 6c 3b 20  |ype: text/html; |
00000060  63 68 61 72 73 65 74 3d  75 74 66 2d 38 0d 0a 43  |charset=utf-8..C|
00000070  6f 6e 74 65 6e 74 2d 4c  65 6e 67 74 68 3a 20 31  |ontent-Length: 1|
00000080  33 34 0d 0a 43 6f 6e 6e  65 63 74 69 6f 6e 3a 20  |34..Connection: |
00000090  63 6c 6f 73 65 0d 0a 0d  0a                       |close....|
00000099
BytesFieldValue
0–13Ethernet headerto the kernel’s side of tap0, from us · type 08 00 = IPv4
14–33IPv4 header10.0.0.2 → 10.0.0.1 · length 01 11 = 273 · protocol 06 = TCP
34–37Ports (SrcPort → DstPort)00 50 → c6 8a = 80 → 50826: the request’s ports, swapped
38–41Sequence number (Seq)00 00 03 e9 = 1001: our first byte of data (we started at 1000)
42–45Acknowledgment (Ack)13 e8 5a af = 333994671 = the GET’s seq + 72: “got all 72 bytes”
46–47Header length + flags (Flags)50 18: 20-byte header · PSH + ACK
48–53Window, checksum, urgentff ff = 65535 · 5c f6 · 00 00
54–70Status lineHTTP/1.0 200 OK
71–150HeadersContent-Type, Content-Length: 134, Connection: close
151–152Blank line0d 0a: the headers end here
153–286Body134 bytes of HTML, not shown

The HTTP code returns plain text. TCP, then IPv4, then Ethernet each put a header in front, the same work the kernel did for curl.

Optional: the security layer

APPLICATIONHTTP/1 on net.Listener
SECURITYTLS 1.2: Go’s library or our own
CONTROLICMP
TRANSPORTTCP
NETWORKIPv4
LINKEthernet + ARP
WIRELinux TAP device

TLS: secure transport. Nobody in between can read or change the bytes. It runs on the same TCP code, unchanged.

TLS takes a net.Conn and returns a net.Conn

http1.ServeConn(c)               the same HTTP code, with or without TLS
   │  Read / Write: plaintext
   ▼
TLS conn         net.Conn        tls.Conn (Go’s) or mintls.Conn (ours)
   │  Read / Write: records, a 5-byte header + encrypted payload
   ▼
tcp.StreamConn   net.Conn        our TCP
   │  segments
   ▼
tcp.Listener     net.Listener

The interface is the same above and below, so TLS is optional: slot it in or leave it out. HTTP can’t tell.

Go’s TLS or ours: one line apart

internal/https1/server.go · diy.go

// -app https: Go’s crypto/tls
tlsConn := tls.Server(raw, cfg)    // *tls.Conn is a net.Conn
tlsConn.Handshake()
s.http.ServeConn(tlsConn)

// -app https-diy: our mintls
tlsConn := mintls.Server(raw, cfg) // *mintls.Conn is a net.Conn
tlsConn.Handshake(cfg)
s.http.ServeConn(tlsConn)

raw is the StreamConn our Listener handed out. Neither TLS library knows that the TCP underneath is ours.

Or let Go do everything above TCP

cmd/mytcp/main.go · -app https-go

ln := stack.TCP().Listen(443)      // ours: a net.Listener
tlsLn := tls.NewListener(ln, cfg)  // Go’s: wraps any net.Listener
srv := &http.Server{Handler: page} // Go’s: serves any net.Listener
srv.Serve(tlsLn)

Every layer above TCP is now the standard library, running on the TCP we wrote. Interfaces are the only thing connecting them.

Go’s crypto/tls vs our mintls

crypto/tlsmintls
VersionsTLS 1.0–1.3 (we set 1.2+)TLS 1.2 only
Cipher suitesmanyexactly one (AES-128-GCM)
Rolesclient and serverserver only
Handshakeruns on first Read or Writeyou call Handshake(cfg)
Alertsfull set, incl. close_notifysends none; any alert = EOF
Size~14,500 lines~1,070 lines
Interfacenet.Connnet.Conn

curl needs --tls-max 1.2 to talk to mintls. Small on purpose, so you can read all of it.

Peeling TLS

Inside mintls: another onion, with cryptography inside.

TLS has layers of its own

┌──────────┬─────────────┬────────────┬──────────────────────┐
│ type (1) │ version (2) │ length (2) │ payload              │
└──────────┴─────────────┴────────────┴──────────────────────┘
  type: 20 ChangeCipherSpec  21 Alert  22 Handshake  23 AppData

internal/mintls/record.go

// every TLS record starts with a 5-byte header
hdr := make([]byte, 5)
io.ReadFull(r, hdr)                      // block until all 5 arrive
typ  = hdr[0]                            // which kind of TLS message
vers = binary.BigEndian.Uint16(hdr[1:3]) // 0x0303 means TLS 1.2
n   := binary.BigEndian.Uint16(hdr[3:5]) // how long the payload is

// then read exactly that many bytes
payload = make([]byte, n)
io.ReadFull(r, payload)                  // ← the next layer's message

Inside mintls.Conn: records in, records out

internal/mintls/conn.go (trimmed)

func (c *Conn) Read(p []byte) (int, error) {
    for len(c.rbuf) == 0 {
        typ, vers, payload, _ := readRecord(c.raw) // raw is our StreamConn
        plain, _ := c.in.open(typ, vers, payload)  // check the tag, decrypt
        c.rbuf = append(c.rbuf, plain...)
    }
    // … copy out of rbuf
}

func (c *Conn) Write(p []byte) (int, error) {
    // for each chunk of at most 4096 bytes:
    enc := c.out.seal(recordApplicationData, VersionTLS12, chunk) // encrypt, add tag
    writeRecord(c.raw, recordApplicationData, VersionTLS12, enc)
}

The same shape as StreamConn one layer down: Read drains a buffer, and Write turns bytes into the next layer’s units. Records here, segments there.

The TLS 1.2 handshake: agreeing on keys

sequenceDiagram participant C as Client participant S as Server C->>S: ClientHello · random, cipher suites S->>C: ServerHello · random, chosen suite S->>C: Certificate · X.509, P-256 public key S->>C: ServerKeyExchange · ECDHE key, signed S->>C: ServerHelloDone C->>S: ClientKeyExchange · ECDHE public key C->>S: ChangeCipherSpec + Finished · encrypted S->>C: ChangeCipherSpec + Finished · encrypted Note over C,S: HTTP inside AES-GCM records

That’s two round trips before the first byte of HTTP. TLS 1.3 cuts it to one.

The four jobs cryptography does in TLS

JobQuestionIn mintls
Key agreementHow do we share a secret over a wire anyone can read?ECDHE on P-256
AuthenticationAm I talking to the right server?ECDSA signature + X.509 cert
Key derivationHow do we turn one secret into many keys?TLS 1.2 PRF (HMAC-SHA256)
Record protectionCan anyone read or tamper with the bytes?AES-128-GCM (AEAD)

Each row is a field of study. We only glue them together.

Both sides compute a secret without sending it

internal/mintls/conn.go · Handshake

// a fresh key pair, used for this connection only
ecdhe, _ := ecdh.P256().GenerateKey(rand.Reader)
// send our public half, signed with the certificate's key so the
// client knows it's really us (ch = the decoded ClientHello)
ske, _ := encodeServerKeyExchange(ch.random, serverRandom,
    ecdhe.PublicKey().Bytes(), cert.PrivateKey)
// the client sends its public half back; combining it with our
// private half gives a secret only the two of us can compute
clientPub, _ := ecdh.P256().NewPublicKey(clientPubBytes)
preMaster, _ := ecdhe.ECDH(clientPub)
// mix in both randoms to get the 48-byte master secret
master := masterSecret(preMaster, ch.random, serverRandom)

The key pair is thrown away after each connection, so stealing the certificate key later can’t decrypt recorded traffic. This is called forward secrecy.

Turning one secret into four keys

internal/mintls/prf.go

// prf12 = TLS 1.2's pseudo-random function: HMAC-SHA256 in a loop,
// stretching (secret, label, seed) into as many bytes as we ask for
// step 1: shared secret → 48-byte master secret
func masterSecret(preMaster, clientRandom, serverRandom []byte) []byte {
    seed := append(clientRandom, serverRandom...)
    return prf12(preMaster, "master secret", seed, 48)
}
// step 2: master secret → 40 bytes, cut into four pieces
func keyBlock(master, clientRandom, serverRandom []byte) (...) {
    seed := append(serverRandom, clientRandom...)
    block := prf12(master, "key expansion", seed, 40)
    return block[0:16], block[16:32], // AES key: client→server, server→client
        block[32:36], block[36:40]    // IV prefix: same two directions
}

The Finished message is derived the same way from a hash of the whole handshake, so any tampering is caught.

Encrypting a record with AES-GCM

internal/mintls/record.go · seal (paraphrased)

// ("||" means "followed by")
// the nonce must never repeat under one key; seq counts records 0, 1, 2…
nonce := fixedIV(4) || seq(8)
// extra data the tag protects, even though it stays readable
aad := seq(8) || type(1) || version(2) || length(2)
// encrypt, and append a 16-byte tag covering ciphertext and aad
ciphertext := h.aead.Seal(nil, nonce, plaintext, aad)
h.seq++
// what actually goes into the record
record payload = explicitNonce(8) || ciphertext || tag(16)

The header isn’t encrypted, but it is checked. Reordering, replaying or changing a single bit makes decryption fail.

The same GET, plain and encrypted

curl http://10.0.0.2/ · captures/talk-http.jsonl frame 7

00000000  02 00 00 00 00 02 ee ea  f3 0f 57 32 08 00 45 00  |..........W2..E.|
00000010  00 70 56 ec 40 00 40 06  cf 99 0a 00 00 01 0a 00  |.pV.@.@.........|
00000020  00 02 c6 8a 00 50 13 e8  5a 67 00 00 03 e9 50 18  |.....P..Zg....P.|
00000030  fa f0 91 b1 00 00 47 45  54 20 2f 20 48 54 54 50  |......GET / HTTP|
00000040  2f 31 2e 31 0d 0a 48 6f  73 74 3a 20 31 30 2e 30  |/1.1..Host: 10.0|
00000050  2e 30 2e 32 0d 0a 55 73  65 72 2d 41 67 65 6e 74  |.0.2..User-Agent|
00000060  3a 20 63 75 72 6c 2f 37  2e 38 38 2e 31 0d 0a 41  |: curl/7.88.1..A|
00000070  63 63 65 70 74 3a 20 2a  2f 2a 0d 0a 0d 0a        |ccept: */*....|
0000007e

curl -k https://10.0.0.2/ through mintls · captures/talk-https-diy.jsonl frame 31

00000000  02 00 00 00 00 02 aa 0b  aa 94 e9 f3 08 00 45 00  |..............E.|
00000010  00 8d 13 f3 40 00 40 06  12 76 0a 00 00 01 0a 00  |....@.@..v......|
00000020  00 02 ce 9e 01 bb 6f 3a  1b 39 00 00 06 54 50 18  |......o:.9...TP.|
00000030  f9 5a c9 13 00 00 17 03  03 00 60 91 a2 0a d9 8a  |.Z........`.....|
00000040  04 d7 9f 38 10 ff fe b5  c3 77 07 da 77 69 85 3c  |...8.....w..wi.<|
00000050  a0 87 8e 2c 7c e8 54 56  b3 a1 80 0b b3 b9 99 08  |...,|.TV........|
00000060  98 a4 48 41 01 4f 6e d8  1e d1 92 9b 37 44 2d 7a  |..HA.On.....7D-z|
00000070  f6 8a 88 66 45 c2 08 88  6b 41 60 40 27 02 1a a3  |...fE...kA`@'...|
00000080  73 52 91 8a 84 3c 50 cd  88 7e 9a ad 26 5f 2a 1c  |sR...<P..~..&_*.|
00000090  e5 ab d3 8a 80 39 34 71  bf 80 c9                 |.....94q...|
0000009b

Ethernet IPv4 TCP HTTP (72) TLS header: 17 = app data, 03 03 = TLS 1.2, 00 60 = 96 nonce (8) ciphertext (72) tag (16)

Bytes 0–53 have the same layout; only lengths, IDs, ports, sequence numbers, checksums and tap0’s MAC differ. 96 = 8 + 72 + 16: the ciphertext is as long as the plain GET, so encryption hides the content, not the size.

What we wrote vs what we borrowed

We wrote

  • Record framing and demux
  • Handshake state machine
  • Handshake hash and Finished checks
  • Key derivation (the PRF)

We used from Go (crypto/*)

  • AES and GCM
  • ECDH, ECDSA on P-256
  • SHA-256, HMAC
  • X.509 certificates

Build the protocol to learn it. Never write your own cipher code.

What mintls leaves out, and what TLS 1.3 changed

mintls doesn’t do

  • Any cipher suite but one
  • Resumption, client certificates, renegotiation
  • Proper error alerts
  • Protection against timing attacks

TLS 1.3 changed

  • One round trip instead of two
  • The certificate is encrypted too
  • New key derivation (HKDF), AEAD ciphers only
  • Forward secrecy is always on

Rabbit holes elliptic-curve math · AEAD design · PKI, cert chains, Certificate Transparency · side channels · post-quantum key exchange (hybrid ML-KEM)

Every stage ended with a working demo

What started workingHow we proved it
Reading raw frames from TAP-dump-only prints every frame
ARP and IPv4 + ICMPping 10.0.0.2
TCP handshake, data, resendsnc to an echo app
HTTP (ours, or Go’s net/http)curl http://10.0.0.2/
TLS (Go’s, or our mintls)curl -k https://10.0.0.2/
Connecting out (SYN_SENT)mytcp -get https://… via NAT

We didn’t start a layer until the one below it worked.

Where each layer lives in the repo

LayerPackage in internal/
Wiretap
Linketh, arp
Networkip4, icmp
Transporttcp
Securitymintls (our TLS), https1 (wiring)
Applicationhttp1
Gluestack: reads type fields and calls the next decoder

From ping to HTTPS, live

Demo commands

# start the Linux lab container and build mytcp inside it
make lab-up && make lab-build

# shell A: run our stack on tap0 serving HTTP, and save a packet capture
/tmp/mytcp -i tap0 -app http -dump=false -pcap captures/latest
# same Listener, Go’s server instead:  -app http-go

# shell B: talk to it with ordinary tools
ping -c 3 10.0.0.2       # exercises ARP + ICMP
curl http://10.0.0.2/    # ARP + IP + TCP + HTTP

# HTTPS: restart shell A with -app https (Go’s TLS), -app https-go
# (all stdlib above TCP) or -app https-diy (our mintls), then
# (-k accepts our self-signed cert; mintls only speaks TLS 1.2)
curl -k --tlsv1.2 --tls-max 1.2 https://10.0.0.2/
/tmp/mytcp -i tap0 -get https://www.google.com/   # other way: Dial, via NAT

What you just saw

  • ping = ARP + ICMP
  • curl = ARP + IP + TCP + HTTP
  • curl -k = the same path, with TLS between TCP and HTTP
  • mytcp -get = the same layers, with us asking first

Standing on giants

Nothing here was invented alone.

Great systems are built by many people

Every layer we lean on is the sum of decades of shared work.

  • Standards (RFCs) agreed between rival vendors, often after failures
  • One web request passes through a NIC, a kernel, a TLS library, curl and a CDN
  • Our few thousand lines stand on millions of hours

That isn’t a caveat. It’s the point.

Good protocols leave room for what comes next

They didn’t predict the future. They drew clean boundaries that later ideas could plug into.

Design choiceWhat it made possible
IP only promises best-effort deliveryReliability could be added on top (TCP)
TCP offers a plain stream of bytesNew apps never needed changes to IP
Every header has a “what’s inside” fieldEach layer can carry many kinds of next layer
TLS sits above TCPEncryption is optional

What could plug in next

  • Real congestion control for our TCP
  • A toy SRD: reliable datagrams over several paths, delivered out of order
  • UDP, and QUIC on top of it, next to TCP
  • Tracing each packet where one layer hands off to the next
  • TLS 1.3, or keep mintls as a simple TLS 1.2 example

None of these need changes to Ethernet, ARP or IP.

Every layer is a discipline

LayerWe builtWhere people spend careers
WireTAP devicekernel networking, eBPF/XDP, NIC hardware
LinkEthernet + ARPswitching, VLANs, data-center fabrics
NetworkIPv4 + ICMProuting (BGP), IPv6, NAT, SDN
Transportminimal TCPcongestion control, QUIC, SRD, RDMA
Securitymintls (TLS 1.2)cryptography, PKI, protocol analysis
ApplicationHTTP/1HTTP/2–3, APIs, CDNs, caching

Peeling the onion once gives you a map. Pick a layer and keep digging.

Takeaways

  1. Build it once — the “magic” turns into headers you can read
  2. Stand on giants — every layer is decades of shared work
  3. Clean boundaries beat prediction — they let SRD, TLS and QUIC plug in
  4. Same shape everywhere — header, Decode / Encode, payload
  5. Pick a layer and dig — each one is a whole discipline

mytcp

Q&A

We didn’t invent the Internet in an afternoon — and that’s the gift.

Someone left us layers.

docs/layers-and-onions.md · docs/presentation.md · make smoke