One Web Request, End to End: Watch curl Cross DNS, TCP, TLS, and HTTP in Order
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.
This is the capstone. Every earlier lab looked at one layer in isolation. Here you run a single command — curl https://www.example.lab/ — and watch it cross all of them, in order:
- DNS: resolve
www.example.labto an address (Labs 05–06). - TCP: three-way handshake to that address (Lab 07).
- TLS: handshake with SNI and ALPN (Lab 09).
- HTTP: the request and the
200response (Labs 10–11).
We resolve a name with DNS, connect with TCP, handshake with TLS, and receive a 200 over HTTP — following that whole sequence as one request path.
Reading guide: rfc-notes/e2e-web-request.md
Prerequisites: Labs 05–11 (DNS, TCP, TLS, HTTP, HTTP/2).
Expected time: 60–80 minutes.
The Goal
By the end, you should be able to narrate the whole path:
www.example.lab
│ DNS A? ─────────────► resolver (10.0.1.2)
│ ◄──────── A 10.0.2.2
│ TCP SYN ─────────────► web (10.0.2.2:443)
│ ◄──────── SYN,ACK
│ TLS ClientHello (SNI=www.example.lab, ALPN=h2) ─►
│ ◄──────── ServerHello, {cert, finished}
│ HTTP GET / (HTTP/2) ──►
│ ◄──────── 200, "Hello from example.lab ..."
▼
What You Will Learn
- The order in which the layers run for one web request.
- Which node each layer talks to (resolver vs web server).
- How the output of one layer (an address) becomes the input of the next.
- How to point at each layer in two captures (DNS on one link, TCP/TLS/HTTP on the other).
This lab does not cover:
- Real public DNS, CAs, or the Internet.
- Performance, connection reuse, or HTTP/3 migration (see Lab 11).
- Load balancing, proxies, or CDNs.
Where to Read in the RFCs
No new RFCs this time. Instead, revisit the RFCs from the earlier labs with a new lens: the order in which they run.
| Layer | RFC | What to revisit |
|---|---|---|
| DNS | RFC 1034 / 1035 | Resolving a name to an address (Labs 05–06) |
| TCP | RFC 9293 | Establishing a connection with the handshake (Lab 07) |
| TLS | RFC 8446 / 6066 / 7301 | SNI and ALPN, and where encryption begins (Lab 09) |
| HTTP | RFC 9110 / 9113 | Request/response and version negotiation (Labs 10–11) |
The Big Picture
The client sits in the middle, with the DNS server on one side and the web server on the other.
client ---- eth1 ---- dns (resolver + authoritative for example.lab)
| www.example.lab. A 10.0.2.2
+------- eth2 ---- web (Caddy: TLS + HTTP/2 over TCP)
The client's /etc/resolv.conf points at dns (10.0.1.2). curl https://www.example.lab/ first performs the DNS lookup over eth1, then runs TCP/TLS/HTTP over eth2 to the returned address, 10.0.2.2.
sequenceDiagram
participant C as client
participant D as dns 10.0.1.2
participant W as web 10.0.2.2
C->>D: DNS A? www.example.lab
D-->>C: A 10.0.2.2
C->>W: TCP SYN
W-->>C: SYN,ACK
C->>W: ClientHello (SNI, ALPN h2)
W-->>C: ServerHello, {cert}, {finished}
C->>W: HTTP/2 GET /
W-->>C: 200 "Hello from example.lab ..."
The images are built by run.sh from dns/Dockerfile (BIND + iproute2) and web/Dockerfile (Caddy + iproute2). The client is netshoot.
Note: Everything here uses local, lab-only address space and a private internal CA, so nothing in this lab touches the real internet.
What You Need
Recommended environment:
- Linux / WSL2 / a Linux VM
- Docker
- containerlab
Images used:
protocol-lab/bind9:9.20(built locally)protocol-lab/caddy:2(built locally)nicolaka/netshoot:latest
Running the Lab
The quick path:
./scripts/labctl.sh run e2e-12
labctl.sh run e2e-12 handles everything: building both images, deploying, configuring the resolver, capturing each layer, firing the single request, verifying layer by layer, and cleaning up.
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/e2e-12
2. Build and deploy
docker build -t protocol-lab/bind9:9.20 ./dns
docker build -t protocol-lab/caddy:2 ./web
sudo containerlab deploy -t e2e-12.clab.yml
3. Point the client at the resolver
docker exec clab-e2e-12-client sh -c "printf 'nameserver 10.0.1.2\n' > /etc/resolv.conf"
4. Layer 1 (DNS): resolve the name
docker exec clab-e2e-12-client dig www.example.lab A
The ANSWER SECTION shows www.example.lab. ... A 10.0.2.2. That address becomes the destination for the next layer.
5. Layers 2–4 (TCP/TLS/HTTP): send one request
Set up two captures — one for DNS, one for the web link.
docker exec -d clab-e2e-12-client tcpdump -i eth1 -n -w /tmp/e2e-dns.pcap "udp port 53"
docker exec -d clab-e2e-12-client tcpdump -i eth2 -n -s0 -w /tmp/e2e-web.pcap "tcp port 443"
Then cross every layer with a single command.
docker exec clab-e2e-12-client curl -k --http2 -v https://www.example.lab/
Read the curl -v output top to bottom and the layer ordering is right there:
* Host www.example.lab:443 was resolved. <- DNS result
* Trying 10.0.2.2:443...
* Connected to www.example.lab (10.0.2.2) <- TCP established
* ALPN: offers h2
* SSL connection using TLSv1.3 ... <- TLS
* ALPN: server accepted h2
> GET / HTTP/2 <- HTTP
< HTTP/2 200
Hello from example.lab, served over HTTP/2.0
6. Point at each layer in the captures
docker exec clab-e2e-12-client tcpdump -n -r /tmp/e2e-dns.pcap # DNS query/response
docker exec clab-e2e-12-client tcpdump -n -r /tmp/e2e-web.pcap # SYN, TLS records, ...
e2e-dns.pcap: the DNS A query and its response oneth1.e2e-web.pcap: the TCP handshake oneth2, then the TLS records (the ClientHello is plaintext; everything after is encrypted).
Expected Output
dig:www.example.lab. A 10.0.2.2.curl -v:Connected to www.example.lab (10.0.2.2),TLSv1.3,ALPN: server accepted h2,HTTP/2 200, and the bodyHello from example.lab ....- DNS capture: the A query/response on
eth1. - Web capture: the SYN, then TLS, on
eth2.
Why It Works
One web request runs as a chain of independent layers, where each layer's output becomes the next layer's input.
- DNS: the name
www.example.labalone is not something you can connect to. The client first asks the resolver and gets the address10.0.2.2(Labs 05–06). That result is the next layer's destination. - TCP: a three-way handshake establishes a connection to port 443 at that address (Lab 07), producing a reliable byte stream.
- TLS: a handshake runs on top of that stream. The ClientHello carries
www.example.labin SNI and selectsh2via ALPN. Once keys are agreed, everything after is encrypted (Lab 09). - HTTP: inside the encrypted stream, the client sends
GET /and receives a200with the body (Labs 10–11).
The important part is the separation of concerns: DNS only resolves names, TCP only delivers bytes, TLS only handles encryption and authentication, and HTTP only carries meaning. Each layer stays simple on its own, and stacked together they form one secure web request.
This lab makes that stacking visible, in order, with one command and two captures.
Common Pitfalls
- The order of the layers. DNS → TCP → TLS → HTTP. TLS comes after TCP and before HTTP.
- Which node each layer talks to. DNS talks to the resolver (
10.0.1.2); everything else talks to web (10.0.2.2). That's exactly why the capture is split in two. resolv.conf. If you don't configure which resolver the client uses, name resolution goes somewhere else entirely.- Why
-kis needed. The web server uses a self-signed certificate from an internal CA, not a public CA. - The encryption boundary. In the web capture you can read up to the TLS ClientHello, but the HTTP payload is encrypted (a Lab 09 refresher).
- One command, many layers.
curlperforms DNS → TCP → TLS → HTTP internally, in order.-vshows that sequence top to bottom.
Cleanup
sudo containerlab destroy -t e2e-12.clab.yml --cleanup
If you used labctl.sh run e2e-12, the script runs destroy for you at the end.
Check Your Understanding
- Name the four layers
curl https://www.example.lab/uses, in order. - Which node (dns or web) does each layer talk to, besides the client?
- The output of DNS (an address) becomes the input of which layer?
- Does TLS come before or after TCP? Before or after HTTP? Why that order?
- In the web capture, which layer is encrypted and unreadable? Up to what point can you read?
- Explain what each layer is responsible for, from a "separation of concerns" point of view.
References
- RFC 1034 / 1035: Domain Names
- RFC 9293: TCP
- RFC 8446: TLS 1.3
- RFC 9110 / 9113: HTTP Semantics and HTTP/2
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
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
- dns:
protocol-lab/bind9:9.20, a thin wrapper aroundinternetsystemsconsortium/bind9:9.20(BIND 9.20.24) - web:
protocol-lab/caddy:2,caddy:2plus iproute2 (internal CA) - client:
nicolaka/netshoot:latest(curl 8.21.0)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run e2e-12 performed deploy → verify → destroy, and verification.json returned "status": "verified".
Environment drift / design fixes this verification required
- The BIND image moved to an Alpine base. examples/e2e-12/dns/Dockerfile was updated to
apk add iproute2+ENTRYPOINT []+ foregroundnamed -g(same as Lab 05). - The internal CA cannot issue a certificate for a bare
:443site. The site in examples/e2e-12/web/Caddyfile was changed towww.example.lab:443(the bind stays on wildcard:443). Since the client connects using the real namewww.example.labresolved via DNS, the SNI matches this site. - The health check now connects by name.
wait_readyinrun.shused to hit the web server by IP (https://10.0.2.2/), but a named site won't present a certificate on an SNI mismatch. Sinceset_resolvhas already run, it now hitshttps://www.example.lab/instead.
Layer 1 (DNS): resolving the name
$ docker exec clab-e2e-12-client dig @10.0.1.2 www.example.lab A
;; ANSWER SECTION:
www.example.lab. 300 IN A 10.0.2.2
The client points its resolv.conf at the dns node (10.0.1.2) and resolves www.example.lab to the web node's address, 10.0.2.2.
Layers 2–4 (TCP + TLS + HTTP): a single request
$ docker exec clab-e2e-12-client curl -k --http2 -sv https://www.example.lab/
* Trying 10.0.2.2:443...
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519MLKEM768 / id-ecPublicKey
* ALPN: server accepted h2
* issuer: CN=Caddy Local Authority - ECC Intermediate
* using HTTP/2
< HTTP/2 200
Hello from example.lab, served over HTTP/2.0
The client connects over TCP to the 10.0.2.2 it got from DNS, completes a TLS 1.3 handshake (ALPN h2, certificate from Caddy's internal CA), and receives a 200 plus the body over HTTP/2.
All the layers, in order, on the wire
Capturing the client's eth1 (DNS) and eth2 (web) simultaneously, the timestamps show the TCP handshake starting immediately after the DNS response arrives:
# eth1: DNS (udp/53)
13:17:59.124646 IP 10.0.1.1.33829 > 10.0.1.2.53: A? www.example.lab. (56)
13:17:59.125014 IP 10.0.1.2.53 > 10.0.1.1.33829: 1/0/1 A 10.0.2.2 (88)
# eth2: TCP+TLS+HTTP (tcp/443) — the SYN goes out 0.1 ms after the DNS response
13:17:59.125132 IP 10.0.2.1.60632 > 10.0.2.2.443: Flags [S], ...
13:17:59.125155 IP 10.0.2.2.443 > 10.0.2.1.60632: Flags [S.], ...
The instant the A record for www.example.lab comes back (...125014), the client sends a SYN to that address (...125132). DNS → (TCP → TLS → HTTP): every lab so far, folded into one curl.
Cleanup
containerlab destroy -t e2e-12.clab.yml --cleanup
That's one web request, end to end: four simple layers, each doing one job, chained so that the output of one becomes the input of the next — all visible in a single curl -v and two packet captures.
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, we'll break out of the single request path and look at what happens when the network itself has to make choices — routing, and how packets find their way.