Chris Lorenzo

homelab and infrastructure notes · stayz3ro

entry-44

The HTTPS request that hung while ping worked

A request from my WSL2 workstation to my self-hosted git server hung forever. Ping, SSH, and a small TLS alert all succeeded. The path was dropping large packets and sending nothing back.

revisions rev. 1 first set 2026-10-23 last revised 2026-10-23 5 min read

I tried to reach my self-hosted git server over HTTPS from a WSL2 workstation. The request never failed cleanly. curl sat there until I killed it. No bytes came back. Ping to the same machine was fine. SSH printed a banner immediately. Even a bare TLS alert, the kind a server sends when you open the port without a real name, came back. The path was up, the port was open, and the one thing I actually needed was stuck.

That combination looks like a firewall. I spent the first pass there.

It looked like an access rule

The git server sits on my tailnet. I reach it from WSL2 on a Windows desktop. The small checks all passed, and the hang was specific to a normal HTTPS request that sends the server name. The easy story is an access control list that allows SSH and blocks the web port, or a firewall that drops the handshake after the first packet.

I checked the boring version of that. A TCP connection to the web port, by address rather than by name, completed. The server answered with a TLS alert. An alert is a tiny record. It means the port is open, the list let the packet through, and a process was listening. A rule that blocked the web port would have refused the connection, or timed out before any TLS bytes. This one did not.

I also had a hostname mix-up in the same hour. Two names were in play, and I had been aiming one of them at the wrong idea of the host. I fixed the name. The hang did not move. That was a real mistake, and it was not this bug. I am writing it down so I do not get to blame DNS for a size problem again.

Other machines on my tailnet opened the same URL without trouble. Only this workstation hung. That pushed me off “the server is broken” and off “the list hates this port.”

What still fit

Ping is one small packet. An SSH banner is a few small segments. A TLS alert with no server name is one small record. A real HTTPS request is not. The client sends the name, and the server answers with a handshake and a certificate chain. That answer is several full size TCP segments.

The tunnel interface on this workstation advertised an MTU of 1280. TCP inside the tunnel picks a segment size from that number. So the certificate was leaving as inner packets of about that size. If something on the path dropped those and sent nothing back, the client would wait forever. The small probes would still look healthy. That is the signature I had.

The sweep

I pinged the git server with the don’t fragment bit set and walked the size up.

An inner packet of about 1214 bytes came back. Inner packets of 1220 bytes and up vanished. No reply, and no ICMP fragmentation needed either. The path did not say “too big, here is the size I can take.” It ate the packet.

So the real ceiling was about 1214 bytes, while the interface was advertising 1280. Everything between those two sizes left the workstation, died on the way, and produced no error for the sender to react to. That is an MTU blackhole.

The local Ethernet MTU was already 1360, under a normal 1500, so the drop was not my own interface refusing a 1280 byte inner packet. WireGuard adds about 60 bytes on the outside. An inner packet of 1214 becomes an outer UDP packet of about 1274. An inner packet of 1220 becomes a little larger, and that is where the silence started. The place that matches is the Windows side under WSL2: the Hyper-V NAT that carries this guest, on a path that already has a tunnel. The Windows host also runs Tailscale, so two stacks are shaping UDP for the same kind of traffic. I did not need to finish that argument. The sweep already told me the ceiling, and only this nested path had it. Other nodes on my tailnet were fine.

A certificate chain never had a chance. The segments were sized for 1280. They landed in the hole between 1214 and 1280. curl waited, then timed out, with zero bytes received. That matched.

The fix

I set the tunnel interface MTU to 1200. That is 14 bytes under the largest size that still passed, enough headroom that I am not balancing on the exact boundary.

A fresh request returned an HTTP status in a second or two. I used a new curl. An already open TCP session keeps the old segment size, so retrying inside it would still hang and I would have called the fix a failure.

The change does not survive on its own. The Tailscale daemon resets the interface MTU when it restarts, and it would put 1280 back. I persisted it with a systemd drop-in on the workstation: a small unit snippet that sets the daemon MTU to 1200, then a daemon reload and a restart of tailscaled. After the restart the interface still showed 1200, and HTTPS to the git server still completed.

I left the Windows NAT alone. The real defect is over there, dropping large UDP without ICMP. Fixing that would let me return the tunnel to 1280. A 1200 byte MTU costs almost nothing on this workstation, and it is the change I can keep without a theory about Hyper-V.

The rule I keep now

If ping, SSH, and a small TLS alert work, and a normal HTTPS request hangs with no bytes, I do not open the firewall yet. I sweep the path with don’t fragment pings. If a size passes and the next size vanishes with no ICMP back, I have a blackhole, and the interface MTU is a lie. I set the MTU under the last size that passed, and I persist it where the daemon will rewrite it on the next restart. Names and access lists can be wrong at the same time. They were. The hang was the packet size.

Related posts: Forgejo as primary, GitHub as a mirror, Putting Forgejo behind the proxy without opening it.