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: socketconnectsendrecv
Those calls hide the complexity. They are a narrow API; the bytes on the wire are on the other side of them.
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
01 01 pad · timestamps (kind 8): TSval / TSecr for RTT
66–140
HTTP request
75 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
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
Why — a reason to take apart something that already works
Scope — a teaching stack, not production TCP
The onion — every layer has the same shape
A demo — from ping to HTTPS, answered by our code
Giants — great systems are built from many people’s work
Foresight — protocols that shipped and left room for what came later
Where to dig — each layer is a whole field of its own
The pattern
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.
Layer
Document
What the payload can be
Ethernet
RFC 894
IPv4, or ARP (RFC 826). The type field decides.
IPv4
RFC 791
TCP, or ICMP (RFC 792). The protocol field decides.
TCP
RFC 9293
A byte stream: HTTP (RFC 9112), TLS (RFC 5246), or a protocol nobody has written yet.
TLS 1.2
RFC 5246
HTTP again, now encrypted. Or something else that fits in a record.
HTTP/1.1
RFC 9112
A 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.
Building it
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
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
}
# 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
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?”
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.
// 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
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.
01: padding, so the next option lines up on 4 bytes
71–73
Window scale
03 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.
20 bytes, no options · 50 18 = PSH + ACK · seq is the client’s start + 1 · ack 1001
54–125
Payload
72 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 TCP
Why
HTTP
Bytes, until it finds \r\n\r\n
Framing is in the text. A push of chunks is enough.
TLS
Blocking Read / Write
The 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)
}
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
http1
net/http
Decodes
the request line only
request line, headers, body, chunked
Per connection
one request, then FIN
keep-alive: many requests
Limits
64 KiB of headers
timeouts, header size, body size
You write
the bytes on the wire
a Handler, given w and r
Size
~210 lines
~29,000 lines (client and server)
Runs on
net.Listener
net.Listener
Ours shows what HTTP/1 is: text on a stream. Go’s is the one you’d ship.
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.
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
// 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
Job
Question
In mintls
Key agreement
How do we share a secret over a wire anyone can read?
ECDHE on P-256
Authentication
Am I talking to the right server?
ECDSA signature + X.509 cert
Key derivation
How do we turn one secret into many keys?
TLS 1.2 PRF (HMAC-SHA256)
Record protection
Can 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.
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.