<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joseph O'Shaughnessy]]></title><description><![CDATA[Hands-on networking labs, Wireshark packet analysis and troubleshooting walkthroughs across Cisco, Juniper and Fortinet, tested in a real home lab]]></description><link>https://josephoshaughnessy.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad4eef768be819cd453981f/bd6413e8-68e0-4a5c-8b5c-0486949b6466.png</url><title>Joseph O&apos;Shaughnessy</title><link>https://josephoshaughnessy.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 08:18:10 GMT</lastBuildDate><atom:link href="https://josephoshaughnessy.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Finding an MTU Black Hole with ping, tcpdump and Wireshark]]></title><description><![CDATA[Small things work but big transfers hang? Learn to diagnose a Path MTU Discovery black hole with ping and packet captures, and fix it three ways]]></description><link>https://josephoshaughnessy.hashnode.dev/mtu-black-hole</link><guid isPermaLink="true">https://josephoshaughnessy.hashnode.dev/mtu-black-hole</guid><category><![CDATA[networking]]></category><category><![CDATA[Wireshark]]></category><category><![CDATA[troubleshooting]]></category><category><![CDATA[TCP]]></category><category><![CDATA[Linux]]></category><dc:creator><![CDATA[Joseph O'Shaughnessy]]></dc:creator><pubDate>Sun, 18 Oct 2026 20:55:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/e39a374b-0338-40f6-b3fd-16575cd09a01.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You can SSH to the server. The login page loads. Ping works. But the moment someone downloads a large file or opens a big report, it just sits there, and eventually times out. Everyone swears the network is fine, because "I can ping it".</p>
<p>That pattern, where small things work and big things hang, is almost always an MTU problem, and usually a specific one: a Path MTU Discovery black hole. In this post I'll build one in a lab, show exactly what it looks like in ping and in a packet capture, and test three ways of fixing it.</p>
<h2>How Path MTU Discovery is meant to work</h2>
<p>The MTU (maximum transmission unit) is the biggest IP packet a link will carry. On normal Ethernet that's 1500 bytes. Tunnels, PPPoE, VPNs and some WAN links have less room, often somewhere between 1400 and 1492.</p>
<p>Modern hosts send TCP traffic with the <strong>Don't Fragment (DF)</strong> bit set. So when a 1500-byte packet reaches a router whose next link only takes 1400, the router can't split it. It drops the packet and sends an ICMP message back to the sender: <strong>type 3, code 4, "fragmentation needed"</strong>, which includes the MTU it can handle. The sender caches that value and resends smaller packets. That's Path MTU Discovery (PMTUD).</p>
<pre><code class="language-plaintext">web01 (1500)      rtr02             1400 link              client01
  | -- 1500 bytes, DF --&gt; |                                    |
  | &lt;-- ICMP 3/4, mtu 1400 |                                    |
  | -- 1400 bytes, DF ----------------------------------------&gt; |
</code></pre>
<p>It all hinges on that ICMP message getting back. If a firewall somewhere drops it, and plenty block ICMP wholesale, the sender never finds out. It keeps sending 1500-byte packets that keep vanishing. That's the black hole.</p>
<p>One detail matters for the lab: the narrow link has to be <strong>in the middle</strong> of the path. If it's on one of the end hosts, TCP's MSS negotiation during the handshake quietly works around it, and you never see a problem.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/3f70b043-0cd4-46e6-8a78-8d5444d47e7e.png" alt="" style="display:block;margin:0 auto" />

<h2>Lab setup</h2>
<p>Same approach as my <a href="https://josephoshaughnessy.hashnode.dev/tcp-handshake-wireshark">TCP handshake post</a>: Linux network namespaces on one machine, no VMs. This time there are four of them in a line, with a 1400-byte link between the two routers:</p>
<p>Save this as <code>mtu-lab.sh</code> and run it with <code>sudo bash mtu-lab.sh</code>, or grab it, along with a cleanup script, from <a href="https://github.com/LostInEther/network-labs/tree/main/mtu-black-hole">GitHub</a>:</p>
<pre><code class="language-bash">#!/bin/bash
# client01 -- rtr01 ==(1400 MTU link)== rtr02 -- web01
set -e
for ns in client01 rtr01 rtr02 web01; do
  ip netns add $ns
  ip netns exec $ns ip link set lo up
done

ip link add c0  type veth peer name r1a
ip link add r1b type veth peer name r2a
ip link add r2b type veth peer name w0
ip link set c0  netns client01
ip link set r1a netns rtr01
ip link set r1b netns rtr01
ip link set r2a netns rtr02
ip link set r2b netns rtr02
ip link set w0  netns web01

ip netns exec client01 ip addr add 198.51.100.10/24 dev c0
ip netns exec rtr01    ip addr add 198.51.100.1/24  dev r1a
ip netns exec rtr01    ip addr add 192.0.2.1/30     dev r1b
ip netns exec rtr02    ip addr add 192.0.2.2/30     dev r2a
ip netns exec rtr02    ip addr add 203.0.113.1/24   dev r2b
ip netns exec web01    ip addr add 203.0.113.10/24  dev w0

# the narrow link in the middle of the path
ip netns exec rtr01 ip link set r1b mtu 1400
ip netns exec rtr02 ip link set r2a mtu 1400

ip netns exec client01 ip link set c0  up
ip netns exec rtr01    ip link set r1a up
ip netns exec rtr01    ip link set r1b up
ip netns exec rtr02    ip link set r2a up
ip netns exec rtr02    ip link set r2b up
ip netns exec web01    ip link set w0  up

ip netns exec client01 ip route add default via 198.51.100.1
ip netns exec web01    ip route add default via 203.0.113.1
ip netns exec rtr01    ip route add 203.0.113.0/24 via 192.0.2.2
ip netns exec rtr02    ip route add 198.51.100.0/24 via 192.0.2.1
ip netns exec rtr01    sysctl -qw net.ipv4.ip_forward=1
ip netns exec rtr02    sysctl -qw net.ipv4.ip_forward=1
</code></pre>
<p>Then give web01 a small file and a 2 MB file to serve:</p>
<pre><code class="language-bash">mkdir -p ~/mtu-www &amp;&amp; cd ~/mtu-www
echo hello &gt; small.txt
head -c 2000000 /dev/urandom &gt; big.bin
sudo ip netns exec web01 python3 -m http.server 80 &amp;
</code></pre>
<p>One thing worth doing before you capture anything: turn off segmentation offload on the two end hosts. Otherwise the kernel hands tcpdump huge "packets" of 7,000+ bytes before splitting them up, which makes an MTU post very confusing to read.</p>
<pre><code class="language-bash">sudo apt install -y ethtool
sudo ip netns exec web01    ethtool -K w0 tso off gso off gro off
sudo ip netns exec client01 ethtool -K c0 tso off gso off gro off
</code></pre>
<p>When you're done, remove everything with:</p>
<pre><code class="language-bash">for ns in client01 rtr01 rtr02 web01; do sudo ip netns del $ns; done
</code></pre>
<h2>Healthy baseline</h2>
<p>Before breaking anything, check that PMTUD works when ICMP is allowed. Both downloads should succeed:</p>
<pre><code class="language-bash">sudo ip netns exec client01 curl -s -o /dev/null -m 10 -w "%{http_code} %{size_download} bytes\n" http://203.0.113.10/small.txt
sudo ip netns exec client01 curl -s -o /dev/null -m 10 -w "%{http_code} %{size_download} bytes\n" http://203.0.113.10/big.bin
</code></pre>
<p>Now send a full-size ping with DF set. <code>-M do</code> sets DF, and <code>-s 1472</code> plus 28 bytes of IP and ICMP headers makes a 1500-byte packet:</p>
<pre><code class="language-bash">sudo ip netns exec client01 ping -c1 -M do -s 1472 203.0.113.10
</code></pre>
<p>It doesn't get through, but it tells you why:</p>
<pre><code class="language-plaintext">From 198.51.100.1 icmp_seq=1 Frag needed and DF set (mtu = 1400)
</code></pre>
<p>That's rtr01 doing its job: it can't push 1500 bytes onto the 1400-byte link, so it says so. <code>tracepath</code> finds the same thing hop by hop:</p>
<pre><code class="language-bash">sudo ip netns exec client01 tracepath -n 203.0.113.10
</code></pre>
<pre><code class="language-plaintext"> 1?: [LOCALHOST]                      pmtu 1500
 1:  198.51.100.1                     0.026ms
 2:  198.51.100.1                     0.004ms pmtu 1400
 2:  192.0.2.2                        0.010ms
 3:  203.0.113.10                     0.009ms reached
     Resume: pmtu 1400 hops 3 back 3
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/6e537a0c-3ea8-4208-9092-7b620b8c2c92.png" alt="" style="display:block;margin:0 auto" />

<p>This is the behaviour you want on a real network: something tells you the path is smaller, and everything adjusts.</p>
<h2>Break it</h2>
<p>Now do what so many firewalls do by accident: drop the ICMP "fragmentation needed" messages. Add this on both routers:</p>
<pre><code class="language-bash">sudo ip netns exec rtr01 iptables -A OUTPUT -p icmp --icmp-type fragmentation-needed -j DROP
sudo ip netns exec rtr02 iptables -A OUTPUT -p icmp --icmp-type fragmentation-needed -j DROP
</code></pre>
<p>Linux caches the path MTU it learned a minute ago, so clear that too, or the hosts will carry on using 1400 and hide the problem:</p>
<pre><code class="language-bash">sudo ip netns exec client01 ip route flush cache
sudo ip netns exec web01    ip route flush cache
</code></pre>
<p>Run the two downloads again. The small file still works. The big one hangs, then curl gives up:</p>
<pre><code class="language-plaintext">200 6 bytes
000 0 bytes
</code></pre>
<p>That's the symptom from the intro, reproduced. The connection opens fine, the request goes up fine, and then the download never arrives.</p>
<h2>Diagnose it</h2>
<h3>Step 1: ping with different sizes</h3>
<p>The quickest test needs nothing but ping. Try the full-size DF ping again:</p>
<pre><code class="language-bash">sudo ip netns exec client01 ping -c1 -W1 -M do -s 1472 203.0.113.10
</code></pre>
<p>This time there's no "Frag needed" message. It just fails: <code>100% packet loss</code>. Now try a smaller one:</p>
<pre><code class="language-bash">sudo ip netns exec client01 ping -c1 -W1 -M do -s 1372 203.0.113.10
</code></pre>
<p>That works. 1372 plus 28 bytes of headers is exactly 1400. So small packets get through, full-size ones vanish without a word. On a real network you don't know the magic number, so you halve the gap between a size that works and one that fails until you find the largest one that gets through. That's your path MTU.</p>
<p><code>tracepath</code> gives another hint. With the ICMP blocked, it never learns the smaller MTU, so its full-size probes die after the first router:</p>
<pre><code class="language-plaintext"> 1?: [LOCALHOST]                      pmtu 1500
 1:  198.51.100.1                     0.047ms
 2:  no reply
 3:  no reply
     Too many hops: pmtu 1500
</code></pre>
<p>Hop 1 still answers, but everything beyond the narrow link goes quiet, and it still believes the path is 1500. A path that "dies" at the same hop for big packets but not for a normal ping is a strong MTU signal.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/ec5e0ca8-43ba-4cc9-9e7d-7013405927b4.png" alt="" style="display:block;margin:0 auto" />

<h3>Step 2: capture at both ends</h3>
<p>This is where it clicks. Start a capture on each end host, then run the big download:</p>
<pre><code class="language-bash">sudo ip netns exec client01 tcpdump -i c0 -w client.pcap tcp port 80 or icmp &amp;
sudo ip netns exec web01    tcpdump -i w0 -w server.pcap tcp port 80 or icmp &amp;
sudo ip netns exec client01 curl -s -o /dev/null -m 10 http://203.0.113.10/big.bin
</code></pre>
<p>Stop both captures afterwards with <code>sudo pkill tcpdump</code>.</p>
<p><strong>The client</strong> sees a perfectly normal handshake, sends its <code>GET</code>, and receives the first small segment with the HTTP headers. Then nothing at all, until curl gives up 10 seconds later and closes the connection.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/e5d84d53-ce6f-44ed-8034-7d8267b89674.png" alt="" style="display:block;margin:0 auto" />

<p><strong>The server</strong> tells the real story. It's sending data in full-size 1,500-byte packets (1,448 bytes of TCP payload), getting no acknowledgement, and retransmitting the same segment with a growing gap: at about 0.2, 0.6, 1.5, 3.1 and then 6.6 seconds in my lab, roughly doubling each time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/4d889863-6148-4b47-ac33-cc6d5ae905a0.png" alt="" style="display:block;margin:0 auto" />

<p>Three Wireshark filters make this obvious on a busy capture:</p>
<table>
<thead>
<tr>
<th>Filter</th>
<th>What it tells you</th>
</tr>
</thead>
<tbody><tr>
<td><code>tcp.analysis.retransmission &amp;&amp; ip.len &gt; 1400</code></td>
<td>Big packets being retransmitted: the core symptom</td>
</tr>
<tr>
<td><code>icmp.type == 3 &amp;&amp; icmp.code == 4</code></td>
<td>Any "fragmentation needed" messages. In a black hole this shows <strong>nothing</strong>, and that absence is the clue</td>
</tr>
<tr>
<td><code>tcp.len &gt; 0</code></td>
<td>Whether any data is actually arriving at the client</td>
</tr>
</tbody></table>
<p>Put together: the handshake works because SYN packets are tiny, the request works because it's small, and the response dies because it's the first thing big enough to hit the 1400-byte link. And since the ICMP message is being dropped, nobody is ever told.</p>
<h2>Fix it</h2>
<p>I tested three fixes. Between each one, undo the previous fix and run <code>ip route flush cache</code> on client01 and web01, so each test starts clean.</p>
<h3>Fix 1: stop dropping the ICMP (the proper fix)</h3>
<p>Remove the rule from both routers:</p>
<pre><code class="language-bash">sudo ip netns exec rtr01 iptables -D OUTPUT -p icmp --icmp-type fragmentation-needed -j DROP
sudo ip netns exec rtr02 iptables -D OUTPUT -p icmp --icmp-type fragmentation-needed -j DROP
</code></pre>
<p>The big download now finishes in about 7 milliseconds. PMTUD does its job again.</p>
<p>On a real network, this means allowing <strong>ICMP type 3 code 4</strong> through every firewall in the path, and <strong>ICMPv6 type 2 (Packet Too Big)</strong> if you run IPv6. Blocking all ICMP feels secure, but these messages are part of how IP works.</p>
<h3>Fix 2: MSS clamping on the routers</h3>
<p>Sometimes you can't fix the firewall that's dropping ICMP, because it isn't yours. MSS clamping works around it. The router rewrites the Maximum Segment Size (MSS) in the TCP handshake so neither end ever sends segments too big for the path:</p>
<pre><code class="language-bash">sudo ip netns exec rtr01 iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
sudo ip netns exec rtr02 iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
</code></pre>
<p>With the ICMP still blocked, the big download works again, in about 8 milliseconds. You can see why in the capture: web01 offers an MSS of 1460, but the SYN-ACK that reaches client01 says <strong>1360</strong>, which is 1400 minus 40 bytes of IP and TCP headers.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/73dfdcf7-e8fb-42b5-9023-2170112c76fe.png" alt="" style="display:block;margin:0 auto" />

<p>This is the standard fix on VPN, PPPoE and tunnel edges. Cisco IOS does the same with <code>ip tcp adjust-mss</code> on an interface, and FortiGate with <code>set tcp-mss-sender</code> and <code>set tcp-mss-receiver</code> on a firewall policy. The catch: it only helps TCP, and only for traffic passing through the device doing the clamping.</p>
<h3>Fix 3: TCP MTU probing on the server</h3>
<p>Linux can also find the path MTU without ICMP at all, by probing with different segment sizes and watching what gets acknowledged:</p>
<pre><code class="language-bash">sudo ip netns exec web01 sysctl -w net.ipv4.tcp_mtu_probing=1
</code></pre>
<p>With ICMP still blocked and no clamping, the download does finish, but it took <strong>about 6.3 seconds</strong> in my lab, against 7 milliseconds with Fix 1. The server only starts probing after its normal retransmissions fail. It's a reasonable safety net on servers you control, not a fix.</p>
<table>
<thead>
<tr>
<th>Fix</th>
<th>Where</th>
<th>Result in the lab</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td>Allow ICMP type 3 code 4</td>
<td>Every firewall in the path</td>
<td>About 7 ms</td>
<td>The real fix, wherever you control the firewall</td>
</tr>
<tr>
<td>MSS clamping</td>
<td>Router or firewall on the narrow link</td>
<td>About 8 ms, TCP only</td>
<td>VPN, PPPoE and tunnel edges; paths you don't control</td>
</tr>
<tr>
<td>TCP MTU probing</td>
<td>The sending host</td>
<td>Works, but about 6.3 s</td>
<td>A safety net on your own servers</td>
</tr>
</tbody></table>
<h2>Quick reference</h2>
<table>
<thead>
<tr>
<th>Check</th>
<th>Command</th>
<th>Black hole looks like</th>
</tr>
</thead>
<tbody><tr>
<td>Full-size packet with DF</td>
<td><code>ping -M do -s 1472 &lt;host&gt;</code></td>
<td>Fails silently, no "Frag needed" reply</td>
</tr>
<tr>
<td>Smaller packet with DF</td>
<td><code>ping -M do -s 1372 &lt;host&gt;</code></td>
<td>Works; keep adjusting to find the largest size that does</td>
</tr>
<tr>
<td>Path MTU per hop</td>
<td><code>tracepath -n &lt;host&gt;</code></td>
<td>Shows pmtu 1500, then "no reply" for every hop past the narrow link</td>
</tr>
<tr>
<td>Server-side capture</td>
<td><code>tcp.analysis.retransmission &amp;&amp; ip.len &gt; 1400</code></td>
<td>Full-size segments retransmitted with a growing gap</td>
</tr>
<tr>
<td>ICMP in the capture</td>
<td><code>icmp.type == 3 &amp;&amp; icmp.code == 4</code></td>
<td>Nothing at all</td>
</tr>
</tbody></table>
<h2>Wrapping up</h2>
<p>If small things work and big things hang, think MTU before anything else. Ping with DF set at a few sizes, capture at both ends, and look for big retransmitted packets with no "fragmentation needed" message anywhere. Then fix it properly by letting ICMP type 3 code 4 through, and use MSS clamping where you can't.</p>
]]></content:encoded></item><item><title><![CDATA[Diagnosing a Failed TCP Handshake in Wireshark]]></title><description><![CDATA[Three ways a TCP handshake fails, closed port, silent drop and lost SYN-ACK, and how to tell them apart in Wireshark using a free Linux lab]]></description><link>https://josephoshaughnessy.hashnode.dev/tcp-handshake-wireshark</link><guid isPermaLink="true">https://josephoshaughnessy.hashnode.dev/tcp-handshake-wireshark</guid><category><![CDATA[networking]]></category><category><![CDATA[Wireshark]]></category><category><![CDATA[troubleshooting]]></category><category><![CDATA[TCP]]></category><category><![CDATA[Linux]]></category><dc:creator><![CDATA[Joseph O'Shaughnessy]]></dc:creator><pubDate>Sun, 18 Oct 2026 18:01:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/de3f53cb-48b4-4c6c-b631-35e7de6b0e7c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When someone says "the application is down", the quickest way to settle whether it's the network is to look at the TCP handshake. If the three-way handshake never completes, the application never gets a chance to fail on its own.</p>
<p>In this post I'll build a small lab, break the handshake in the three ways I see most often, and show what each one looks like in Wireshark. By the end you'll be able to look at a capture and say, within a minute, whether you're dealing with a closed port, a silent drop or a return-path problem.</p>
<p>Every capture in this post comes from a lab you can build on any Linux machine in about five minutes, with no VMs. The script is included, so you can reproduce each failure yourself.</p>
<h2>The three-way handshake in 60 seconds</h2>
<p>Every TCP connection starts with three packets:</p>
<ol>
<li><p><strong>SYN</strong>: the client asks to open a connection and sends its initial sequence number.</p>
</li>
<li><p><strong>SYN-ACK</strong>: the server agrees, acknowledges the client's sequence number and sends its own.</p>
</li>
<li><p><strong>ACK</strong>: the client acknowledges the server's sequence number. The connection is now established and data can flow.</p>
</li>
</ol>
<pre><code class="language-plaintext">Client                         Server
  | ---- SYN ------------------&gt; |
  | &lt;--- SYN-ACK --------------- |
  | ---- ACK ------------------&gt; |
  |      (connection open)       |
</code></pre>
<p>A healthy handshake usually finishes within a few milliseconds on a LAN. When it doesn't finish, the packets that <em>are</em> there, and the ones that are missing, tell you where to look.</p>
<h2>Lab setup</h2>
<p>The lab is three Linux network namespaces on one machine, joined by virtual Ethernet (veth) pairs. Each namespace has its own interfaces, routing table and firewall, so it behaves like a separate host. It's quicker to build than VMs in EVE-NG or GNS3, and it runs anywhere.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/60237ae6-fd29-4558-90aa-685ac3a704b5.png" alt="Lab topology: client01, rtr01 and web01 joined by veth pairs" style="display:block;margin:0 auto" />

<p>Save this as <code>lab.sh</code> and run it with <code>sudo bash lab.sh</code>, or grab it, along with a cleanup script, from <a href="https://github.com/LostInEther/network-labs/tree/main/tcp-handshake">GitHub</a>:</p>
<pre><code class="language-bash">#!/bin/bash
# client01 (198.51.100.10) -- rtr01 -- web01 (203.0.113.10)
set -e
for ns in client01 rtr01 web01; do
  ip netns add $ns
  ip netns exec $ns ip link set lo up
done

ip link add c-eth0 type veth peer name r-eth0
ip link add r-eth1 type veth peer name w-eth0
ip link set c-eth0 netns client01
ip link set r-eth0 netns rtr01
ip link set r-eth1 netns rtr01
ip link set w-eth0 netns web01

ip netns exec client01 ip link set c-eth0 name eth0
ip netns exec rtr01    ip link set r-eth0 name eth0
ip netns exec rtr01    ip link set r-eth1 name eth1
ip netns exec web01    ip link set w-eth0 name eth0

ip netns exec client01 ip addr add 198.51.100.10/24 dev eth0
ip netns exec rtr01    ip addr add 198.51.100.1/24  dev eth0
ip netns exec rtr01    ip addr add 203.0.113.1/24   dev eth1
ip netns exec web01    ip addr add 203.0.113.10/24  dev eth0

ip netns exec client01 ip link set eth0 up
ip netns exec rtr01    ip link set eth0 up
ip netns exec rtr01    ip link set eth1 up
ip netns exec web01    ip link set eth0 up

ip netns exec client01 ip route add default via 198.51.100.1
ip netns exec web01    ip route add default via 203.0.113.1
ip netns exec rtr01    sysctl -qw net.ipv4.ip_forward=1
</code></pre>
<p>Start a web server on web01, then capture at <strong>both ends</strong> while the client connects:</p>
<pre><code class="language-bash">sudo ip netns exec web01 python3 -m http.server 80 &amp;

sudo ip netns exec client01 tcpdump -i eth0 -w client.pcap tcp port 80 &amp;
sudo ip netns exec web01    tcpdump -i eth0 -w server.pcap tcp port 80 &amp;

sudo ip netns exec client01 curl -m 10 http://203.0.113.10/
</code></pre>
<p>Capturing at both ends is the single most useful habit in handshake troubleshooting. It tells you which side of the path a packet disappeared on, and as you'll see in failure 3, sometimes it's the only way to tell two failures apart.</p>
<p>Open <code>client.pcap</code> in Wireshark and you'll see a healthy handshake. I've added the IP TTL as a column (right-click the TTL field in the packet details, then <em>Apply as Column</em>); it matters later.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/cb49a737-f02e-491c-b4d0-78af729dcf8a.png" alt="Healthy TCP handshake in Wireshark: SYN, SYN-ACK, ACK" style="display:block;margin:0 auto" />

<p>When you're finished, remove the lab with:</p>
<pre><code class="language-bash">for ns in client01 rtr01 web01; do sudo ip netns del $ns; done
</code></pre>
<h2>Filters for finding failed handshakes fast</h2>
<p>In a busy capture you won't spot a failed handshake by scrolling. These display filters do the work:</p>
<table>
<thead>
<tr>
<th>Filter</th>
<th>Shows</th>
</tr>
</thead>
<tbody><tr>
<td><code>tcp.flags.syn == 1 &amp;&amp; tcp.flags.ack == 0</code></td>
<td>Every connection attempt (SYNs only)</td>
</tr>
<tr>
<td><code>tcp.flags.syn == 1 &amp;&amp; tcp.flags.ack == 1</code></td>
<td>Every SYN-ACK reply</td>
</tr>
<tr>
<td><code>tcp.flags.reset == 1</code></td>
<td>Every reset</td>
</tr>
<tr>
<td><code>tcp.analysis.retransmission &amp;&amp; tcp.flags.syn == 1</code></td>
<td>Retransmitted SYNs and SYN-ACKs, the classic sign of a handshake that isn't completing</td>
</tr>
<tr>
<td><code>icmp.type == 3</code></td>
<td>ICMP destination unreachable, which some firewalls send instead of a reset</td>
</tr>
</tbody></table>
<p>Wireshark also has a field called <code>tcp.completeness</code>, which scores each TCP conversation by the flags it has seen: SYN = 1, SYN-ACK = 2, ACK = 4, data = 8, FIN = 16, RST = 32. Recent versions show the same thing as letters in <code>tcp.completeness.str</code>. These are the values from my captures:</p>
<table>
<thead>
<tr>
<th>Conversation</th>
<th><code>tcp.completeness</code></th>
<th><code>.str</code></th>
</tr>
</thead>
<tbody><tr>
<td>Healthy: handshake, data and FIN</td>
<td>31</td>
<td><code>·FDASS</code></td>
</tr>
<tr>
<td>SYN answered with a reset</td>
<td>37</td>
<td><code>R··A·S</code></td>
</tr>
<tr>
<td>SYN and SYN-ACK, no final ACK (server-side capture)</td>
<td>3</td>
<td><code>····SS</code></td>
</tr>
<tr>
<td>SYN and nothing else</td>
<td>1</td>
<td><code>·····S</code></td>
</tr>
</tbody></table>
<p>Note that the reset case is 37, not 33: the RST that answers a SYN also carries the ACK flag, which adds 4. So <code>tcp.completeness == 1</code> finds every attempt that got no answer at all, and <code>tcp.completeness.str contains "R"</code> finds every conversation that was reset. If you use tshark rather than the GUI, add <code>-2</code> (two-pass analysis), or early packets show only the flags seen so far.</p>
<h2>Failure 1: SYN answered with RST</h2>
<p><strong>Reproduce it:</strong> stop the web server on web01 (<code>fg</code>, then Ctrl+C), then run the <code>curl</code> again. It fails instantly with <code>Connection refused</code>.</p>
<p><strong>What Wireshark shows:</strong> two packets. The client's SYN, and an immediate <code>RST, ACK</code> coming back from 203.0.113.10, 45 microseconds later.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/63747960-aca7-4c28-a711-019a94053982.png" alt="	SYN answered by RST, ACK from the server, TTL 63" style="display:block;margin:0 auto" />

<p><strong>What it means:</strong> something answered on the server's behalf and said "no". Most often nothing is listening on that port: the service is stopped, crashed, or bound to a different port or address.</p>
<p><strong>The catch:</strong> the reset isn't always from the server. Firewalls can be configured to reject with a reset, and they use the server's address as the source when they do it. To see this, start the web server again and add this rule on the router:</p>
<pre><code class="language-bash">sudo ip netns exec rtr01 iptables -A FORWARD -p tcp --dport 80 -j REJECT --reject-with tcp-reset
</code></pre>
<p>The client capture looks almost identical, and the RST still claims to come from 203.0.113.10. The giveaway is the <strong>TTL</strong>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/d65cfb5f-bcef-4ce0-b3e4-60a1900797ea.png" alt="RST from the server with TTL 63 vs RST from a firewall with TTL 64" style="display:block;margin:0 auto" />

<ul>
<li><p>The RST from web01 left with a TTL of 64 and crossed one router, so it arrived with <strong>63</strong>.</p>
</li>
<li><p>The RST injected by rtr01 arrived with <strong>64</strong>, because it never crossed a router at all.</p>
</li>
</ul>
<p>There's a second clue if you captured at both ends: in the firewall case, <strong>web01's capture is empty</strong>. The SYN never reached the server, so the server can't have sent that reset.</p>
<p>In a real network, compare the reset's TTL with a normal SYN-ACK from the same server. If they differ, something in the path sent the reset, so look at firewalls and load balancers, not the application.</p>
<p><strong>Next step:</strong> on the server, check what's listening with <code>ss -tlnp | grep :80</code>. If the service is up, work along the path looking for a reject rule.</p>
<p>Clear the rule before moving on: <code>sudo ip netns exec rtr01 iptables -F</code>.</p>
<h2>Failure 2: SYN with no reply</h2>
<p><strong>Reproduce it:</strong> with the web server running, add a silent drop on web01's own firewall:</p>
<pre><code class="language-bash">sudo ip netns exec web01 iptables -A INPUT -p tcp --dport 80 -j DROP
</code></pre>
<p>Run the <code>curl</code> again. This time it hangs, then gives up after 10 seconds with a timeout.</p>
<p><strong>What the client capture shows:</strong> a SYN, then the same SYN retransmitted again and again. Wireshark marks the repeats as <code>[TCP Retransmission]</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/30f1ee7f-7d72-4d40-8843-2f82f59b7e50.png" alt="SYN retransmissions about one second apart with no reply" style="display:block;margin:0 auto" />

<p>Look at the Time column. I expected the gaps to double each time, as the textbooks describe. Instead, the first five retries came about one second apart, and only then did the gap grow, to two seconds. That's because recent Linux kernels (6.5 onwards) keep the first few SYN retries at a fixed one-second interval instead of doubling straight away, controlled by <code>net.ipv4.tcp_syn_linear_timeouts</code>. Older kernels and other operating systems back off differently, so don't read too much into the exact gaps. The pattern that matters is the same SYN, repeated, with nothing coming back.</p>
<p><strong>What it means:</strong> the SYN was dropped somewhere, without any reply.</p>
<p><strong>Where was it dropped?</strong> This is where the server-side capture earns its keep:</p>
<ul>
<li><p><strong>The SYNs appear in the server capture:</strong> they reached the host, so the drop is on the host itself, usually its local firewall. That's what happens here: every SYN shows up in web01's capture, arriving with TTL 63. <code>tcpdump</code> sees packets before the host firewall does, which is why they still appear.</p>
</li>
<li><p><strong>The SYNs don't appear in the server capture:</strong> they were lost on the way. Check firewall policies, ACLs and routing between the two hosts, and capture at each hop until you find where they stop.</p>
</li>
</ul>
<p><strong>Next step:</strong> if the server never sees the SYN, check the path hop by hop. If it does, check the host firewall (<code>iptables -L -n -v</code>, <code>nft list ruleset</code> or Windows Defender Firewall) before touching the application.</p>
<p>Clear the rule before moving on: <code>sudo ip netns exec web01 iptables -F</code>.</p>
<h2>Failure 3: SYN-ACK never acknowledged</h2>
<p><strong>Reproduce it:</strong> on the router, drop only the server's SYN-ACKs on their way back to the client:</p>
<pre><code class="language-bash">sudo ip netns exec rtr01 iptables -A FORWARD -p tcp --sport 80 --tcp-flags SYN,ACK SYN,ACK -j DROP
</code></pre>
<p>Run the <code>curl</code> again. It hangs and times out, exactly like failure 2.</p>
<p><strong>What the captures show:</strong> the client capture is indistinguishable from failure 2: a SYN and its retransmissions, nothing else. The server capture tells a completely different story:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4eef768be819cd453981f/6be9f91d-5f1e-4645-bd4c-e807561bd8ec.png" alt="Client sees only SYN retransmissions while the server sends SYN-ACKs" style="display:block;margin:0 auto" />

<p>The server is doing its job. It replies to the first SYN with a SYN-ACK, retransmits that SYN-ACK when no ACK arrives, and answers every one of the client's retransmitted SYNs with yet another SYN-ACK. None of them ever reach the client.</p>
<p><strong>What it means:</strong> the forward path works but the return path doesn't. In real networks the usual causes are:</p>
<ul>
<li><p><strong>Asymmetric routing through a stateful firewall:</strong> the SYN goes out one way, and the SYN-ACK comes back through a different firewall that never saw the SYN, so it drops the reply as out-of-state.</p>
</li>
<li><p><strong>Wrong default gateway or missing return route on the server:</strong> the SYN-ACK leaves by the wrong interface or next hop.</p>
</li>
<li><p><strong>NAT problems:</strong> the reply doesn't match a translation, so it's dropped or sent to the wrong place.</p>
</li>
</ul>
<p><strong>The lesson:</strong> a client-side capture alone can't tell failure 2 from failure 3. If you only take one thing from this post, take this: capture at both ends before you blame anything.</p>
<p><strong>Next step:</strong> trace the return path from the server (<code>ip route get 198.51.100.10</code>, <code>traceroute</code>), and check the state tables or session logs on any firewall in the path.</p>
<h2>Quick reference</h2>
<table>
<thead>
<tr>
<th>What you see</th>
<th>Likely cause</th>
<th>Next check</th>
</tr>
</thead>
<tbody><tr>
<td>SYN, then immediate RST; RST TTL matches the server's normal replies</td>
<td>Nothing listening on the port</td>
<td><code>ss -tlnp</code> on the server</td>
</tr>
<tr>
<td>SYN, then immediate RST; RST TTL is higher than normal, server capture empty</td>
<td>A firewall in the path rejecting with a reset</td>
<td>Reject rules on firewalls and load balancers</td>
</tr>
<tr>
<td>SYN plus retransmissions; server capture shows the SYNs arriving</td>
<td>Host firewall on the server dropping them</td>
<td>Server firewall rules</td>
</tr>
<tr>
<td>SYN plus retransmissions; server capture shows nothing</td>
<td>Dropped in the path: firewall policy, ACL or routing</td>
<td>Capture hop by hop</td>
</tr>
<tr>
<td>SYN retransmissions at the client; SYN-ACK retransmissions at the server</td>
<td>Return path broken: asymmetric routing, wrong gateway, NAT</td>
<td>Return route from the server; firewall state tables</td>
</tr>
<tr>
<td>ICMP destination unreachable instead of a SYN-ACK</td>
<td>Firewall or router rejecting with ICMP</td>
<td>The rule on the device named as the ICMP source</td>
</tr>
</tbody></table>
<h2>Wrapping up</h2>
<p>The handshake is the first thing to check whenever "the app is down". Three packets tell you whether the network is involved at all, and which side of the path to look at. Filter for SYNs, look at what comes back, check the TTL on any reset, and always capture at both ends.</p>
<p>In a future post I'll take this further into what happens <em>after</em> the handshake: zero windows, retransmissions in the data phase, and MTU problems that only show up once real traffic starts flowing.</p>
]]></content:encoded></item></channel></rss>