惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

云风的 BLOG
云风的 BLOG
Security Archives - TechRepublic
Security Archives - TechRepublic
V
Vulnerabilities – Threatpost
C
CXSECURITY Database RSS Feed - CXSecurity.com
P
Proofpoint News Feed
G
GRAHAM CLULEY
P
Privacy International News Feed
The Hacker News
The Hacker News
Forbes - Security
Forbes - Security
U
Unit 42
N
News and Events Feed by Topic
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
Cyber Attacks, Cyber Crime and Cyber Security
C
Cisco Blogs
A
About on SuperTechFans
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
D
Docker
I
Intezer
Spread Privacy
Spread Privacy
The Last Watchdog
The Last Watchdog
V2EX - 技术
V2EX - 技术
S
Security @ Cisco Blogs
F
Full Disclosure
S
Secure Thoughts
M
MIT News - Artificial intelligence
Microsoft Security Blog
Microsoft Security Blog
G
Google Developers Blog
aimingoo的专栏
aimingoo的专栏
W
WeLiveSecurity
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Project Zero
Project Zero
Recorded Future
Recorded Future
Cyberwarzone
Cyberwarzone
S
Security Affairs
AWS News Blog
AWS News Blog
H
Help Net Security
The GitHub Blog
The GitHub Blog
Hacker News: Ask HN
Hacker News: Ask HN
Vercel News
Vercel News
P
Proofpoint News Feed
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
The Register - Security
The Register - Security
S
Schneier on Security
F
Fortinet All Blogs
C
CERT Recently Published Vulnerability Notes
L
LINUX DO - 最新话题
T
Tor Project blog
T
The Exploit Database - CXSecurity.com
MongoDB | Blog
MongoDB | Blog
Webroot Blog
Webroot Blog

kmcd.dev

Beating Go gRPC-Web Should Have Fixed gRPC Making Dynamic Protobuf Fast in Go Proxy, Record, and Mock gRPC APIs with FauxRPC Exploring Protocol Buffers Interactively Introducing ProtoDocs Ghost in the Shell: The Manga Behind the Anime The Hidden Cost of google.protobuf.Value Why Networking Built Its Own Data Modeling Language Zero-Friction Demos with WASM Let's Learn About BGP ConnectRPC: Where is it now? Building APIs with Contracts The Case for Greppable Code Unknown Fields in Protobuf IRC Log: Reactionary Faking protobuf data in Go Y'all are Sleeping on Mise-en-Place IRC Log: Standup 2 HTTP/2 From Scratch: Part 4 IRC Log: rm -rf /var/opt/gitlab/postgresql/data HTTP/2 From Scratch: Part 3 Building a Live BGP Map IRC Log: The Cloud Scale Incident Visualizing the Internet (2026) Shell Log: Namaste HTTP/2 From Scratch: Part 1 IRC Log: Standup HTTP/1.1 From Scratch WHOIS is dead, long live RDAP Months Considered Harmful Encryption vs. Compression On Creating My Own Cover Art Traceroute Tool from Scratch in Go My Favorite Interview Question From JSON to Protobuf Breaking gRPC Morse Code Can You Hack a Phone with Your Voice? Visualizing the Internet (2025) HTTP QUERY and Go I made a daily word game Protovalidate: Can Input Validation Be This Easy? Behold! The Barcode Scanner Mixing CEL and Protobuf for Fun FauxRPC and Protovalidate The Call of the Monolithic Codebase FauxRPC + Test Containers Self-Documenting Connect Services gRPC Over HTTP/3: Followup JSON to Protobuf Conversion gRPC: The Ugly Parts Working with Protobuf in 2024 Introducing FauxRPC HTTP/1.0 From Scratch Y'all are sleeping on HTTP/3 HTTP/0.9 From Scratch What version of HTTP are you using? Texans in Denmark gRPC Over HTTP/3 gRPC: The Good Parts Leaving Texas for Greener Pastures gRPC: The Bad Parts Unit Testing ConnectRPC Servers Daily Prompts Adding chart.js to Hugo Why I'm Rebranding Benchmarking gRPC (golang) Blog Update gRPC From Scratch: Part 3 - Protobuf Encoding Tracking the Wins Visualizing the Internet (2024) Dropping Unknown Fields in ConnectRPC RESTless: Web APIs After REST Introducing unknownconnect-go Making gRPC more approachable with ConnectRPC Inspecting Protobuf Messages Introducing protoc-gen-connect-openapi gRPC From Scratch: Part 2 - Server gRPC From Scratch: Part 1 - Client Why you should use gNMI over SNMP in 2026 The Rollercoaster of Productivity in Side Projects Lessons from a Decades-Long Project How I learned to code Economists with (virtual) Guns Visualizing the Internet (2023) softlayer-python: language bindings/CLI for a cloud company SwFTP: SFTP/FTP Server For Openstack Swift Video: Morning Copenhagen Commute Goodbye Evepraisal Visualizing the spectrum of the sun (Part 2) Visualizing the Internet (2022) Evepraisal: A price estimation tool for Eve Online Visualizing the spectrum of the sun
HTTP/2 From Scratch: Part 2
2026-02-25 · via kmcd.dev

In the previous post, we successfully performed the TLS handshake and sent our 24-byte connection preface. To the server, we now look like a valid HTTP/2 client. But as soon as that preface is sent, the server starts talking back in a language we haven’t yet taught our Go code to understand.

While HTTP/1.1 communicated in lines of text separated by newlines, HTTP/2 communicates in frames. Every single interaction from here on out (settings, headers, data, and keepalive pings) happens inside of a frame. To move us closer to our goal of understanding HTTP/2, we need to build a parser that can slice into these binary packets and make sense of the bits inside.

The Anatomy of a Frame

According to the specification, every HTTP/2 frame begins with a fixed 9-byte header. This header is the roadmap for the rest of the payload. It tells us exactly how much data follows and how we should interpret it.

The 9-byte header is laid out precisely. Using a packet visualization, we can see how the bits are packed together:

packet-beta
0-23: "Length (24 bits)"
24-31: "Type (8 bits)"
32-39: "Flags (8 bits)"
40: "R (Reserved)"
41-71: "Stream Identifier (31 bits)"
  

The header fields serve specific purposes:

  • Length: The size of the frame payload (up to 16,383 bytes by default).
  • Type: The specific purpose of the frame (e.g., 0x0 for DATA, 0x1 for HEADERS).
  • Flags: Boolean modifiers that change how the frame is processed.
  • Stream Identifier: A unique ID that links the frame to a specific request-response lifecycle.

The first bit of the Stream Identifier is reserved (R) and must remain zero. You will see this a lot in binary protocols, where bits are reserved for the potential of making a backwards compatible next version of the protocol or byte alignment.

Modeling the Header in Go

To handle this in Go, we need a structure that mirrors the spec. While the length and stream ID are 24 and 31 bits respectively, we use uint32 to store them and handle the bit-masking manually during the read.

type FrameHeader struct {
    Length   uint32
    Type     uint8
    Flags    uint8
    StreamID uint32
}

The challenge here is that Go’s encoding/binary package doesn’t have a direct way to read a 24-bit integer. We have to read three bytes and manually shift them into place. We are no longer working with strings; we are working with bitwise operators.

Thinking in Bits

Before we write the parser, we need to get comfortable with Bit Shifting. In Go, we use the << (left shift) and | (bitwise OR) operators to “stitch” separate bytes into a single number.

Think of it like a conveyor belt. To turn three separate bytes into one 24-bit integer, we take the first byte and slide it 16 places to the left, move the second byte 8 places to the left, and then lay them all on top of each other using the OR operator.

In Go, uint32(data[0]) << 16 tells the compiler to take an 8-bit byte, treat it as a 32-bit number, and move its value to the “high” end of the bucket. HTTP/2 always uses network byte order (big-endian). Go’s binary.BigEndian makes this explicit when reconstructing integers.

Bitwise operations take some getting used to, so it’s worth spending time with them if this feels unfamiliar. I recommend Chapter 27: Enum, Iota & Bitmask from Practical Go Lessons.

Parsing the Bytes

When the server responds, we read the first 9 bytes from our tls.Conn. We then use bit-shifting to reconstruct the fields. For the Stream Identifier, we use binary.BigEndian.Uint32 and then mask out the first bit.

We also need to handle the Reserved (R) bit in the Stream Identifier. Even though we read 4 bytes (32 bits), the spec says the first bit must be ignored. We use the bitwise AND operator with 0x7FFFFFFF, which is a binary 0 followed by thirty-one 1s—to “mask out” that first bit and ensure our ID is always interpreted correctly.

func ReadFrameHeader(data []byte) FrameHeader {
    // Length is 24 bits: first 3 bytes shifted and OR'd
    length := uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
    
    // Type and Flags are single bytes
    fType := data[3]
    flags := data[4]
    
    // Stream ID is 31 bits. We read 4 bytes starting at offset 5, 
    // then mask out the reserved bit (0x7FFFFFFF)
    streamID := binary.BigEndian.Uint32(data[5:9]) & 0x7FFFFFFF

    return FrameHeader{
        Length:   length,
        Type:     fType,
        Flags:    flags,
        StreamID: streamID,
    }
}

The SETTINGS Frame

Immediately after receiving our preface, the server will send a SETTINGS frame (Type 0x04). This is the protocol’s way of establishing the rules of the road. It defines limits like how many concurrent streams we can open or how large our header table can be.

The payload of a SETTINGS frame consists of a series of 6-byte parameters:

packet-beta
title SETTINGS Parameter (6 Bytes)
0-15: "Identifier (16 bits)"
16-47: "Value (32 bits)"
  

For now, the most important thing is that we simply acknowledge these settings. HTTP/2 requires that when you receive a SETTINGS frame, the client must send back a SETTINGS frame with the ACK flag set (bit 0x1). If you don’t, the server will eventually assume the client is unresponsive and close the connection.

Putting it into Practice

We can now update our client to read the header, read the payload based on the length we just parsed, and log what we found. This is where the binary nature of the protocol becomes visible in your terminal.

full parser.go (click to expand)View on GitHub
package main

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

// FrameHeader represents the 9-byte fixed header of every HTTP/2 frame.
type FrameHeader struct {
	Length   uint32
	Type     uint8
	Flags    uint8
	StreamID uint32
}

// Frame represents a complete HTTP/2 frame including its payload.
type Frame struct {
	Header  FrameHeader
	Payload []byte
}

// ReadFrame reads a header and then the corresponding payload from the connection.
func ReadFrame(r io.Reader) (Frame, error) {
	// 1. Read the 9-byte header
	headerBuf := make([]byte, 9)
	_, err := io.ReadFull(r, headerBuf)
	if err != nil {
		return Frame{}, fmt.Errorf("reading header: %w", err)
	}

	// 2. Parse the header fields using bit-shifting
	header := FrameHeader{
		Length:   uint32(headerBuf[0])<<16 | uint32(headerBuf[1])<<8 | uint32(headerBuf[2]),
		Type:     headerBuf[3],
		Flags:    headerBuf[4],
		StreamID: binary.BigEndian.Uint32(headerBuf[5:9]) & 0x7FFFFFFF,
	}

	// 3. Read the payload based on the Length field
	payload := make([]byte, header.Length)
	if header.Length > 0 {
		_, err = io.ReadFull(r, payload)
		if err != nil {
			return Frame{}, fmt.Errorf("reading payload: %w", err)
		}
	}

	return Frame{Header: header, Payload: payload}, nil
}

Running this against kmcd.dev will show you exactly what Cloudflare or your server expects. You’ll see a Type: 4 frame with a specific length. This is the first time our code is truly listening to an HTTP/2 server.

What’s Next?

We can now read the frame header and the raw payload, but the raw payload represents a HEADERS frame and we have no idea how to actually parse that yet. In the next post, we will tackle the most complex part of the HTTP/2 specification: HPACK.

We need to learn how to compress and decompress headers using a stateful table, which is the only way we will ever be able to send a real GET request and get a real response.

See all of the code mentioned in this article here:

go/client.go (click to expand)View on GitHub
package main

import (
	"crypto/tls"
	"fmt"
	"log"
)

const (
	// The "Magic" preface required by RFC 9113
	preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
	server  = "kmcd.dev:443"
)

func main() {
	// We configure TLS to specifically look for "h2" via ALPN
	config := &tls.Config{
		NextProtos: []string{"h2"},
	}

	// Dial the server and perform the handshake
	conn, err := tls.Dial("tcp", server, config)
	if err != nil {
		log.Fatalf("Failed to connect: %v", err)
	}
	defer conn.Close()

	// Ensure the server actually agreed to speak HTTP/2
	state := conn.ConnectionState()
	if state.NegotiatedProtocol != "h2" {
		log.Fatalf("Server did not negotiate HTTP/2: %s", state.NegotiatedProtocol)
	}

	fmt.Printf("Connected to %s using %s\n", server, state.NegotiatedProtocol)

	// Send the Connection Preface to initialize the H2 session
	_, err = conn.Write([]byte(preface))
	if err != nil {
		log.Fatalf("Failed to send preface: %v", err)
	}

	fmt.Println("Preface sent successfully. The connection is open.")

	frame, err := ReadFrame(conn)
	if err != nil {
		log.Fatalf("Failed to read server settings: %v", err)
	}

	// Check the Type on the Header field of our Frame struct
	if frame.Header.Type != 0x04 {
		log.Fatalf("Expected SETTINGS frame (0x04), got: %d", frame.Header.Type)
	}
	fmt.Printf("Received SETTINGS: %d bytes on Stream %d\n", frame.Header.Length, frame.Header.StreamID)

	// 2. Acknowledge the settings frame
	ackHeader := []byte{
		0x00, 0x00, 0x00, // Length: 0
		0x04,                   // Type: SETTINGS
		0x01,                   // Flags: ACK (0x01)
		0x00, 0x00, 0x00, 0x00, // Stream ID: 0 (Connection level)
	}

	if _, err = conn.Write(ackHeader); err != nil {
		log.Fatalf("Failed to send SETTINGS ACK: %v", err)
	}
	fmt.Println("Sent SETTINGS ACK.")
}
go/parser.go (click to expand)View on GitHub
package main

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

// FrameHeader represents the 9-byte fixed header of every HTTP/2 frame.
type FrameHeader struct {
	Length   uint32
	Type     uint8
	Flags    uint8
	StreamID uint32
}

// Frame represents a complete HTTP/2 frame including its payload.
type Frame struct {
	Header  FrameHeader
	Payload []byte
}

// ReadFrame reads a header and then the corresponding payload from the connection.
func ReadFrame(r io.Reader) (Frame, error) {
	// 1. Read the 9-byte header
	headerBuf := make([]byte, 9)
	_, err := io.ReadFull(r, headerBuf)
	if err != nil {
		return Frame{}, fmt.Errorf("reading header: %w", err)
	}

	// 2. Parse the header fields using bit-shifting
	header := FrameHeader{
		Length:   uint32(headerBuf[0])<<16 | uint32(headerBuf[1])<<8 | uint32(headerBuf[2]),
		Type:     headerBuf[3],
		Flags:    headerBuf[4],
		StreamID: binary.BigEndian.Uint32(headerBuf[5:9]) & 0x7FFFFFFF,
	}

	// 3. Read the payload based on the Length field
	payload := make([]byte, header.Length)
	if header.Length > 0 {
		_, err = io.ReadFull(r, payload)
		if err != nil {
			return Frame{}, fmt.Errorf("reading payload: %w", err)
		}
	}

	return Frame{Header: header, Payload: payload}, nil
}