HTTP/1.1 in the Clear: Read One Request and Response Line by Line, Then Watch 304 Save the Day
This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.
TCP carried bytes; TLS could wrap them. This lab looks at the bytes themselves: one HTTP/1.1 request and its response, in cleartext, so you can name every line.
Reading guide: rfc-notes/http-requests-responses-caching.md
Prerequisite: TCP Lab 07: One Connection, From SYN to FIN
Expected time: 45–60 minutes.
The Goal
You will send four requests and read each result:
GET /→ 200 OK withCache-ControlandETag,HEAD /→ 200 headers only, no body,GET /withIf-None-Match→ 304 Not Modified (the cached copy is still fresh),GET /missing→ 404 Not Found.
By the end, you should be able to label this exchange:
> GET / HTTP/1.1 <- request line: method, target, version
> Host: 10.0.0.2:8080 <- request header
>
< HTTP/1.1 200 OK <- status line
< Content-Type: text/plain; charset=utf-8
< Content-Length: 40
< Cache-Control: max-age=60 <- how long a cache may reuse this
< ETag: "v1-abc123" <- a validator for conditional requests
<
Hello from the Protocol Lab HTTP server.
What You Will Learn
- The shape of an HTTP message: start line, headers, blank line, body.
- What the GET and HEAD methods do, and how HEAD differs from GET.
- How status codes group into 2xx / 3xx / 4xx and what 200, 304, and 404 mean.
- What
Cache-ControlandETagare for. - How a conditional request (
If-None-Match) produces304 Not Modifiedand saves a body.
This lab does not cover:
- HTTPS/TLS on top of HTTP (that was Lab 09).
- HTTP/2 framing and multiplexing (that is Lab 11).
- A full cache implementation,
Vary, or revalidation edge cases. - Cookies, auth, or redirects in depth.
Where to Read in the RFCs
The current HTTP specification is RFC 9110–9112 (2022).
| RFC | Section | What to focus on |
|---|---|---|
| RFC 9110 | 3, 6 | The concepts of resource, representation, and message |
| RFC 9110 | 9 | Methods (the definitions of GET / HEAD) |
| RFC 9110 | 15 | Status codes (what 200 / 304 / 404 mean) |
| RFC 9111 | 5.2 | Cache-Control directives |
| RFC 9111 | 4.3 | Validation and conditional requests (ETag / If-None-Match / 304) |
| RFC 9112 | 2–3 | HTTP/1.1 message syntax (start line, headers, body) |
The Big Picture
Same two-node topology as Lab 07. The server is a small Python HTTP server. No TLS — we observe everything in cleartext.
client (10.0.0.1) ------ eth1/eth1 ------ server (10.0.0.2:8080)
python3 app.py
GET / -> 200 (+Cache-Control, ETag)
If-None-Match -> 304
/missing -> 404
Both nodes run nicolaka/netshoot (which bundles curl, python3, and tcpdump). No additional images are required.
What You Need
Recommended environment:
- Linux / WSL2 / a Linux VM
- Docker
- containerlab
Images used:
nicolaka/netshoot:latest
Running the Lab
The quick path, which deploys, starts the HTTP server, runs all four curl requests, checks the statuses, headers, and cache behavior, and tears down for you:
./scripts/labctl.sh run http-10
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/http-10
2. Read the server, then start it
cat server/app.py
sudo containerlab deploy -t http-10.clab.yml
docker exec -d clab-http-10-server python3 /app/app.py
app.py is a tiny server that returns 200 plus cache headers for GET /, a 304 when If-None-Match matches, and a 404 for /missing.
3. Send GET / (200 with cache headers)
docker exec clab-http-10-client curl -v http://10.0.0.2:8080/
What to look for:
> GET / HTTP/1.1
> Host: 10.0.0.2:8080
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Content-Length: 40
< Cache-Control: max-age=60
< ETag: "v1-abc123"
Lines starting with > are the request you sent; lines starting with < are the response that came back (curl's notation).
4. Send HEAD / (headers only)
docker exec clab-http-10-client curl -v -I http://10.0.0.2:8080/
HEAD returns the same headers as GET but no body. Content-Length is present, yet the body is empty.
5. Send a conditional GET (304)
Take the ETag you just saw, put it in If-None-Match, and GET again.
docker exec clab-http-10-client curl -v -H 'If-None-Match: "v1-abc123"' http://10.0.0.2:8080/
What to look for:
< HTTP/1.1 304 Not Modified
< ETag: "v1-abc123"
< Cache-Control: max-age=60
304 means "the copy you already have is still fresh — I'm not sending the body." This cuts both network traffic and server load.
6. Hit a path that doesn't exist (404)
docker exec clab-http-10-client curl -v http://10.0.0.2:8080/missing
< HTTP/1.1 404 Not Found
7. Confirm it's cleartext with a capture
docker exec clab-http-10-client sh -c \
"tcpdump -i eth1 -A -s0 'tcp port 8080' & sleep 1; curl -s http://10.0.0.2:8080/ >/dev/null; sleep 1; pkill tcpdump"
With -A, tcpdump prints the payload as ASCII, and you can read GET / HTTP/1.1 and HTTP/1.1 200 OK right off the wire. There is no TLS, so any on-path observer sees the contents — a direct contrast with Lab 09.
Expected Output
GET /:HTTP/1.1 200 OK,Cache-Control: max-age=60,ETag: "v1-abc123".HEAD /: the same headers, no body.- Conditional
GET:HTTP/1.1 304 Not Modified, no body. GET /missing:HTTP/1.1 404 Not Found.- The capture: request line, status line, and headers all readable in cleartext.
Why It Works
HTTP is a protocol for exchanging representations of resources via request/response. One message is, in order: a start line (a request line or a status line), a set of headers, a blank line, and a body.
- Methods say "what do I want to do."
GETretrieves;HEADsays "just the headers, no body please." That makes HEAD cheap to transfer — handy for checking whether something exists or how big it is. - Status codes classify the outcome.
2xxis success,3xxmeans further action,4xxis a client-side problem.200is a successful fetch,404means "not here," and304means "unchanged." - Cache headers:
Cache-Control: max-age=60says "you may reuse this as-is for 60 seconds."ETagis an identifier — a validator — for this particular representation. - Conditional requests: next time around, the client (or a cache) asks with
If-None-Match: <etag>. If nothing changed, the server answers304with no body; if it did change, it answers200with the new body. That's how HTTP avoids re-sending things that haven't changed.
The key insight: HTTP's semantics — methods, status codes, headers — read as a layer that is completely independent of the TCP and TLS underneath.
Common Pitfalls
- Expecting HEAD to return a body. HEAD is headers only.
Content-Lengthis present, but there is no body. - Reading 304 as an error. 304 is a successful optimization: "nothing changed, so I'm not sending it."
- Mixing up the roles of
Cache-ControlandETag.max-agesays how long you may reuse a copy (freshness);ETagis the mark you use to check whether it's still the same (validation). - Assuming curl caches on its own. curl does not cache. What this lab shows is the caching mechanism — the headers and the 304. Browsers and CDNs are what actually use it.
- Forgetting this is cleartext. This lab is plain HTTP (no encryption), which is exactly why the capture is readable. Real deployments use HTTPS.
- The
Hostheader. Mandatory in HTTP/1.1. It lets one IP address serve multiple sites apart — playing a role similar to what SNI does for TLS.
Cleanup
sudo containerlab destroy -t http-10.clab.yml --cleanup
If you used labctl.sh run http-10, the script runs destroy for you at the end.
Check Your Understanding
- What are the four components of an HTTP message, starting with the start line?
- How do GET and HEAD differ? What is HEAD useful for?
- What do 200 / 304 / 404 each mean, and which group (2xx/3xx/4xx) does each belong to?
- What are
Cache-Control: max-age=60andETageach for? - When does a conditional
GET(If-None-Match) return304? What does that save? - Why can this lab's traffic be read in a capture? How does that differ from Lab 09?
References
- RFC 9110: HTTP Semantics
- RFC 9111: HTTP Caching
- RFC 9112: HTTP/1.1
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
- curl manual page
Verified Run Log (2026-07-05)
This lab has been confirmed reproducible on real hardware.
Environment:
- Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
- Docker 29.1.3
- containerlab 0.77.0
- client / server:
nicolaka/netshoot:latest(bundlescurl/python3; the server runsserver/app.py)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run http-10 performed deploy → verify → destroy, and verification.json returned "status": "verified". No image or topology fixes were needed.
GET / (200 OK plus cache headers)
$ curl -sv http://10.0.0.2:8080/
< HTTP/1.1 200 OK
< Server: protocol-lab/1.0 Python/3.14.5
< Content-Type: text/plain; charset=utf-8
< Content-Length: 41
< Cache-Control: max-age=60
< ETag: "v1-abc123"
Cache-Control: max-age=60 means "you may reuse this as-is for 60 seconds." The ETag is the version identifier for this representation — we'll use it for the conditional GET next.
HEAD / (headers only, no body)
$ curl -sv -I http://10.0.0.2:8080/
< HTTP/1.1 200 OK
< Content-Length: 41
< Cache-Control: max-age=60
< ETag: "v1-abc123"
The same headers come back as for GET — including Content-Length: 41 — but no body is transferred.
Conditional GET (If-None-Match → 304 Not Modified)
$ curl -sv -H 'If-None-Match: "v1-abc123"' http://10.0.0.2:8080/
> If-None-Match: "v1-abc123"
< HTTP/1.1 304 Not Modified
< ETag: "v1-abc123"
The client sends the ETag it already holds in If-None-Match, and the server replies "unchanged" — 304 Not Modified with no body. That's revalidation: permission to keep reusing the cached representation.
GET /missing (404 Not Found)
$ curl -sv http://10.0.0.2:8080/missing
< HTTP/1.1 404 Not Found
A path that doesn't exist gets a 404.
Cleanup
containerlab destroy -t http-10.clab.yml --cleanup
That's one HTTP/1.1 exchange, read in the clear: a start line, some headers, a blank line, and a body — with Cache-Control, ETag, and 304 quietly doing the work of never re-sending what hasn't changed.
Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.
Next up in Lab 11, we'll move from readable text to binary frames and see how HTTP/2 multiplexes many streams over one connection.