One TCP Connection, From SYN to FIN: Capture the Whole Lifecycle in tcpdump

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.

The DNS track ended with a name resolved to an address. This lab takes the next step: open one TCP connection to that kind of address and watch its entire lifecycle in a packet capture — from the first SYN to the last ACK.

You will capture and annotate:

  • the three-way handshake: SYN, SYN-ACK, ACK,
  • one small data exchange with sequence and acknowledgment numbers,
  • the four-way teardown: FIN, ACK, FIN, ACK.

Reading guide: rfc-notes/tcp-handshake-teardown.md

Expected time: 50–65 minutes.

The Goal

By the end, you should be able to label each packet in this sketch:

client                          server
  | ---- SYN  seq=x ----------->  |   connection requested
  | <--- SYN,ACK seq=y ack=x+1 -  |   accepted, server ISN
  | ---- ACK  ack=y+1 --------->  |   handshake complete (ESTABLISHED)
  | ==== data / echo =========>   |   one small exchange
  | ---- FIN ----------------->   |   client done sending
  | <--- ACK ------------------   |
  | <--- FIN ------------------   |   server done sending
  | ---- ACK ----------------->   |   both sides closed

What You Will Learn

  • Why opening a TCP connection takes three packets, not one.
  • What SYN, ACK, FIN, and RST flags mean in a capture.
  • How the initial sequence number (ISN) and ack = seq + 1 tie the handshake together.
  • Why closing is a four-way exchange (each direction closes independently).
  • How to read tcpdump flag notation: [S], [S.], [.], [P.], [F.], [R].

This lab does not cover:

  • Retransmission, windowing, and loss recovery (that is Lab 08).
  • Congestion control algorithms.
  • TLS or any application-layer protocol on top of TCP.
  • TCP options in depth (MSS, window scaling, SACK, timestamps).

Where to Read in the RFCs

The required reading this time is below. RFC 9293 is the current TCP specification, replacing RFC 793.

RFC Section What to focus on
RFC 9293 3.1 Header format and control bits (SYN/ACK/FIN/RST)
RFC 9293 3.4 Sequence numbers, the ISN, and the ack = seq + 1 idea
RFC 9293 3.5 Connection establishment (the 3-way handshake)
RFC 9293 3.6 Connection termination (the FIN-based 4-way close)
RFC 9293 3.3.2 State transitions (LISTEN / SYN-SENT / ESTABLISHED / FIN-WAIT / TIME-WAIT)
RFC 5737 3 Confirming the addresses used in this lab are documentation-only

The Big Picture

The topology couldn't be simpler: two nodes joined by a single link. The client connects once to a TCP port on the server.

client (10.0.0.1) ------ eth1/eth1 ------ server (10.0.0.2:8080)

The server runs an echo listener (it returns whatever bytes it receives). The client sends one line, receives the echo, and closes the connection. The whole time, tcpdump runs on the client side so the handshake through the teardown lands in a single pcap.

sequenceDiagram
  participant C as client 10.0.0.1
  participant S as server 10.0.0.2:8080

  Note over C,S: 3-way handshake
  C->>S: SYN seq=x
  S->>C: SYN,ACK seq=y ack=x+1
  C->>S: ACK ack=y+1
  Note over C,S: ESTABLISHED

  C->>S: PSH,ACK "hello-tcp\n"
  S->>C: ACK
  S->>C: PSH,ACK "hello-tcp\n" (echo)
  C->>S: ACK

  Note over C,S: 4-way teardown
  C->>S: FIN,ACK
  S->>C: ACK
  S->>C: FIN,ACK
  C->>S: ACK

Both nodes use nicolaka/netshoot. It ships ip, tcpdump, nc (OpenBSD netcat), and socat, so no additional images are needed. The echo server is built with socat instead of ncat (the nmap variant with --exec), which netshoot no longer includes.

Note: The addresses used here are local, documentation-style space (RFC 5737), so nothing in this lab touches the real internet.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM
  • Docker
  • containerlab
  • tcpdump (on the host; the netshoot containers bundle it too)

Images used:

  • nicolaka/netshoot:latest

Running the Lab

These steps run inside the Linux environment where containerlab is installed.

If you have the repo checked out, the quick path runs the whole verification for you:

./scripts/labctl.sh run tcp-07

labctl.sh run tcp-07 deploys the topology, starts the echo listener, collects the tcpdump capture, runs one connection, verifies the handshake and teardown, and destroys the lab.

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/tcp-07

2. Deploy

sudo containerlab deploy -t tcp-07.clab.yml
docker ps --format "table {{.Names}}\t{{.Status}}"

Confirm that clab-tcp-07-client and clab-tcp-07-server are up.

3. Start the echo listener on the server

Start a tiny TCP server in the background that returns whatever bytes it receives.

docker exec -d clab-tcp-07-server socat TCP-LISTEN:8080,reuseaddr,fork EXEC:/bin/cat

4. Arm the capture on the client

In a separate shell, run tcpdump on the client-side link (foreground is fine if you're watching live).

docker exec -it clab-tcp-07-client tcpdump -i eth1 -n "tcp port 8080"

The -n flag disables name resolution, so addresses and flags read exactly as they are.

5. Connect exactly once

In yet another shell, send one line from the client, receive the echo, and close.

docker exec -it clab-tcp-07-client sh -c "printf 'hello-tcp\n' | nc -w2 10.0.0.2 8080"

If hello-tcp comes back as the echo, the connection was established, data made a round trip, and the connection closed.

6. Read the capture

The tcpdump side shows a sequence like this (the values change every run):

10.0.0.1.44002 > 10.0.0.2.8080: Flags [S],  seq 1000000000, ...          <- SYN
10.0.0.2.8080 > 10.0.0.1.44002: Flags [S.], seq 2000000000, ack 1000000001 <- SYN-ACK
10.0.0.1.44002 > 10.0.0.2.8080: Flags [.],  ack 1                          <- ACK (handshake complete)
10.0.0.1.44002 > 10.0.0.2.8080: Flags [P.], seq 1:11, ack 1, length 10     <- data "hello-tcp\n"
10.0.0.2.8080 > 10.0.0.1.44002: Flags [P.], seq 1:11, ack 11, length 10    <- echo
10.0.0.1.44002 > 10.0.0.2.8080: Flags [F.], seq 11, ack 11                 <- FIN (client done sending)
10.0.0.2.8080 > 10.0.0.1.44002: Flags [F.], seq 11, ack 12                 <- FIN (server done sending)
10.0.0.1.44002 > 10.0.0.2.8080: Flags [.],  ack 12                         <- final ACK

tcpdump flag notation:

Notation Meaning
[S] SYN
[S.] SYN + ACK
[.] ACK only
[P.] PSH + ACK (carries data)
[F.] FIN + ACK
[R] RST

Expected Output

Rather than an exact byte-for-byte match, what matters is that you can find:

  • One [S] (SYN) and one [S.] (SYN-ACK) at the start.
  • The SYN-ACK's ack equal to the SYN's seq + 1.
  • Data lines (length > 0) in both directions.
  • [F.] (FIN) from both sides, closed out by a final [.] (ACK).

Why It Works

TCP builds a reliable byte stream on top of unreliable IP. To do that, both ends agree on where the other side starts counting (the ISN) before any data is sent.

  • Why three packets? The client's SYN declares "I will send starting from seq=x." The server ACKs it (ack=x+1) while returning its own SYN (seq=y). The client then ACKs the server's SYN (ack=y+1), and the initial sequence numbers are pinned down in both directions. One round trip can only synchronize one direction, so at least three packets are needed.
  • Why ack = seq + 1? A SYN consumes one sequence number (even though it carries no data). So the peer replies with seq + 1 as the next number it expects. A FIN consumes one as well.
  • Why does closing take four packets? Each direction of a TCP connection closes independently (half-close). When the client sends its FIN, the server may still have data to send. So each direction closes on its own: "client's FIN → server's ACK," then "server's FIN → client's ACK." Because this lab's echo server closes immediately, the server's ACK and FIN can appear merged together.

The state transitions you're observing, from the client's point of view: SYN-SENT → ESTABLISHED → FIN-WAIT-1 → FIN-WAIT-2 → TIME-WAIT.

Common Pitfalls

  • Thinking of the handshake as "one shake." It's actually three packets. Count SYN, SYN-ACK, and ACK separately.
  • Trying to memorize absolute seq/ack values. The ISN is random. What matters is the relationship (ack = peer's seq + 1). tcpdump shows relative sequence numbers (starting at 1) by default, which makes the capture easier to read.
  • Reading [.] as "nothing." [.] is an ACK-only packet. It appears as the third packet of the handshake and as acknowledgments of data.
  • Confusing FIN with RST. FIN is a polite close; RST is an abort (nobody listening, connection refused, and so on). How a connection is closed determines which one you see.
  • Capture timing. Start tcpdump before connecting. Get the order wrong and you'll miss the SYN.
  • Half-close. Closing happens per direction. One side sending its FIN doesn't stop the other side from sending more data.

Cleanup

sudo containerlab destroy -t tcp-07.clab.yml --cleanup

If you used labctl.sh run tcp-07, the script runs destroy for you at the end.

Check Your Understanding

  1. Why does establishing a TCP connection take three packets? What would be missing with just one?
  2. What is the relationship between the SYN-ACK's ack and the client SYN's seq? Why +1?
  3. What do tcpdump's [S], [S.], [.], [F.], and [R] each represent?
  4. Why does closing a connection take four packets (or close to it)? What is a half-close?
  5. What's the difference between a FIN close and an RST close?
  6. In the capture, after which packet does the client enter ESTABLISHED?

References

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

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run tcp-07 performed deploy → verify → destroy, and verification.json returned "status": "verified".

Keeping up with environment drift (a fix this verification required)

  • netshoot's netcat had been swapped out. The current nicolaka/netshoot no longer ships nmap's ncat (with --exec); it ships OpenBSD netcat (nc) and socat instead. OpenBSD nc has no --exec/-e, so the echo server is now started with socat TCP-LISTEN:8080,reuseaddr,fork EXEC:/bin/cat, and the client send switched to nc -w2 (both examples/tcp-07/run.sh and the lab steps were updated).

The full lifecycle of one connection (tcpdump)

The client (10.0.0.1) sends one line to the server (10.0.0.2:8080), receives the echo, and closes. Every packet in between:

$ docker exec clab-tcp-07-client tcpdump -tttt -n -r /tmp/tcp-07.pcap

12:57:10.386664 IP 10.0.0.1.59614 > 10.0.0.2.8080: Flags [S],  seq 65907414, win 56760, options [mss 9460,sackOK,TS ...,nop,wscale 10], length 0
12:57:10.386683 IP 10.0.0.2.8080 > 10.0.0.1.59614: Flags [S.], seq 2744956622, ack 65907415, win 56688, options [mss 9460,sackOK,...], length 0
12:57:10.386691 IP 10.0.0.1.59614 > 10.0.0.2.8080: Flags [.],  ack 1, win 56, length 0
12:57:10.386714 IP 10.0.0.1.59614 > 10.0.0.2.8080: Flags [P.], seq 1:11, ack 1, win 56, length 10   # "hello-tcp\n" sent
12:57:10.386716 IP 10.0.0.2.8080 > 10.0.0.1.59614: Flags [.],  ack 11, win 56, length 0
12:57:10.387210 IP 10.0.0.2.8080 > 10.0.0.1.59614: Flags [P.], seq 1:11, ack 11, win 56, length 10  # echo returned
12:57:10.387214 IP 10.0.0.1.59614 > 10.0.0.2.8080: Flags [.],  ack 11, win 56, length 0
12:57:12.388376 IP 10.0.0.1.59614 > 10.0.0.2.8080: Flags [F.], seq 11, ack 11, win 56, length 0     # client FIN
12:57:12.388472 IP 10.0.0.2.8080 > 10.0.0.1.59614: Flags [F.], seq 11, ack 12, win 56, length 0     # server FIN
12:57:12.388477 IP 10.0.0.1.59614 > 10.0.0.2.8080: Flags [.],  ack 12, win 56, length 0             # final ACK

How to read it:

  • 3-way handshake: [S] (SYN) → [S.] (SYN-ACK) → [.] (ACK) — the first three lines. In relative seq/ack terms, the SYN's seq comes back as ack 1, and from then on ack 1 means "I've acknowledged the SYN's one byte."
  • Data round trip: the client's [P.] length 10 is hello-tcp\n, and the server's [P.] length 10 is its echo. Each side ACKs the other.
  • 4-way teardown: [F.] (FIN,ACK) is exchanged client-first, then the client closes things out with a final [.] (ACK). Here both sides FIN almost simultaneously, so the exchange collapses into three packets. The nc -w2 idle timeout (about 2 seconds after sending) is what triggers the close — visible in the timestamp gap between lines 7 and 8 (...10.387...12.388).

The echo as received on the client:

$ printf 'hello-tcp\n' | nc -w2 10.0.0.2 8080
hello-tcp

(You may see tcpdump label the length 10 packets as HTTP — that's just a guess based on port 8080; the actual payload is the plain-text hello-tcp.)

Cleanup

containerlab destroy -t tcp-07.clab.yml --cleanup

That's one full TCP lifetime in a single capture: three packets to agree on sequence numbers, one round trip of data, and each direction closing on its own terms.

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, Lab 08 breaks this connection on purpose: retransmission, windowing, and how TCP recovers from loss.

Read more