gw-02 step 01 — Parse the HTTP/2 frame layer

Goal

Decode an HTTP/2 connection at the frame level: the connection preface, the 9-byte frame header, and the common frame types. This makes multiplexing visible — you'll watch frames from multiple streams interleave on one connection.

Background — the preface and frame header

Every h2 connection starts with a fixed client preface then a SETTINGS frame both ways:

client preface (24 bytes): "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
then: SETTINGS frame (client) and SETTINGS frame (server), each ACKed

Frame header (9 bytes), big-endian:

length:  uint24   (payload length, not incl. the 9-byte header)
type:    uint8    (0=DATA 1=HEADERS 3=RST_STREAM 4=SETTINGS
                   6=PING 7=GOAWAY 8=WINDOW_UPDATE ...)
flags:   uint8
stream:  uint31   (high bit reserved; 0 = connection-level)

Code — src/go/frame.go

package h2

import (
	"encoding/binary"
	"fmt"
	"io"
)

type FrameType uint8

const (
	FrameData         FrameType = 0x0
	FrameHeaders      FrameType = 0x1
	FramePriority     FrameType = 0x2
	FrameRSTStream    FrameType = 0x3
	FrameSettings     FrameType = 0x4
	FramePushPromise  FrameType = 0x5
	FramePing         FrameType = 0x6
	FrameGoAway       FrameType = 0x7
	FrameWindowUpdate FrameType = 0x8
	FrameContinuation FrameType = 0x9
)

func (t FrameType) String() string {
	return [...]string{"DATA", "HEADERS", "PRIORITY", "RST_STREAM",
		"SETTINGS", "PUSH_PROMISE", "PING", "GOAWAY", "WINDOW_UPDATE",
		"CONTINUATION"}[t]
}

// Flag bits (meaning depends on frame type).
const (
	FlagEndStream  uint8 = 0x1 // DATA, HEADERS
	FlagAck        uint8 = 0x1 // SETTINGS, PING
	FlagEndHeaders uint8 = 0x4 // HEADERS, CONTINUATION
	FlagPadded     uint8 = 0x8
)

type FrameHeader struct {
	Length   uint32 // 24-bit
	Type     FrameType
	Flags    uint8
	StreamID uint32 // 31-bit
}

type Frame struct {
	Header  FrameHeader
	Payload []byte
}

const ClientPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"

// ReadFrame reads one frame (9-byte header + payload) from r.
func ReadFrame(r io.Reader) (*Frame, error) {
	var hdr [9]byte
	if _, err := io.ReadFull(r, hdr[:]); err != nil {
		return nil, err
	}
	length := uint32(hdr[0])<<16 | uint32(hdr[1])<<8 | uint32(hdr[2])
	fh := FrameHeader{
		Length:   length,
		Type:     FrameType(hdr[3]),
		Flags:    hdr[4],
		StreamID: binary.BigEndian.Uint32(hdr[5:9]) & 0x7fffffff, // mask reserved bit
	}
	payload := make([]byte, length)
	if _, err := io.ReadFull(r, payload); err != nil {
		return nil, err
	}
	return &Frame{Header: fh, Payload: payload}, nil
}

func (f *Frame) String() string {
	end := ""
	if f.Header.Type == FrameData || f.Header.Type == FrameHeaders {
		if f.Header.Flags&FlagEndStream != 0 {
			end = " END_STREAM"
		}
	}
	return fmt.Sprintf("stream=%-3d %-13s len=%-5d flags=0x%02x%s",
		f.Header.StreamID, f.Header.Type, f.Header.Length, f.Header.Flags, end)
}

A "frame sniffer" main — watch multiplexing happen

src/go/cmd/h2sniff/main.go reads a captured h2 byte stream (e.g. from a file you dump, or proxy a real curl --http2-prior-knowledge through an io.TeeReader) and prints frames:

func main() {
	r := bufio.NewReader(os.Stdin)
	pre := make([]byte, len(h2.ClientPreface))
	io.ReadFull(r, pre) // consume the connection preface
	for {
		fr, err := h2.ReadFrame(r)
		if err != nil {
			return
		}
		fmt.Println(fr)
	}
}

Generate a capture and feed it in:

# Cleartext h2 (prior knowledge) so you can read frames without TLS:
nghttpd --no-tls 9000 &                       # or any h2c origin
nghttp -v http://127.0.0.1:9000/ -m 5         # -m 5: 5 concurrent reqs
# nghttp -v already prints frames; the lab's parser reproduces that view
# from raw bytes so you understand the wire, not just the tool's output.

Tasks

  1. Implement ReadFrame and the type/flag decoding.
  2. Feed it an h2 capture with multiple concurrent requests (nghttp -m 8). In the output, find frames with different stream= values interleaved — that's multiplexing you can point at.
  3. Identify the SETTINGS exchange at the start and the END_STREAM flag that closes each request's stream.

Acceptance

  • Your sniffer prints a frame-by-frame trace matching nghttp -v for the same connection (same stream IDs, types, END_STREAM flags).
  • You can point to interleaved stream IDs and explain that one TCP connection is carrying N concurrent requests.

Discussion prompts

  • Why are client-initiated stream IDs always odd? What are even IDs for?
  • A HEADERS frame without END_HEADERS must be followed by CONTINUATION frames — why does that exist, and why is a flood of empty CONTINUATION frames a DoS (the "Rapid Reset" cousin)?
  • Where, exactly, would an L4 LB have to stop to make per-request routing possible? (It can't — it only sees TCP segments, not frames.)