DEV Community

Taylor Wang
Taylor Wang

Posted on

I Trusted curl for 48 Hours. Python Dialed ::1 Anyway.

Have you ever trusted curl more than your own client, then spent two days proving the network was fine? I did that on a throwaway Linux box, and the trail looked clean until the address family appeared. These field notes reconstruct that 48-hour loop instead of inventing a customer outage with pretty latency graphs. The behavior is still current in 2026, and it still misleads you when your laptop resolver is not the machine that serves traffic.

The symptom I started with

Python kept raising ConnectionRefusedError against https://localhost:8443, while a one-line curl against the same URL returned headers. I asked the obvious question first: if curl can complete the handshake, how can the socket layer be the problem? The certificate looked valid enough in the curl verbose log, so I blamed TLS configuration, then the CA bundle, then the server cipher list. None of those theories survived a careful check, because the client never stayed on the address the server had bound.

I needed a Linux resolver, not the one sitting in my laptop network stack. That gap is exactly why a disposable remote shell is useful for this class of bug. If your Mac says localhost is fine, why would a glibc image owe you the same tuple order?

Hours 0–8: the wrong stack

I treated the failure as HTTPS theater and burned the first evening on certificates. I rotated test files, forced TLS 1.2, and pointed SSL_CERT_FILE at a bundle I had just generated. I compared curl -v with a tiny http.client script until the outputs felt like two different machines. They were not two machines. They were two callers of getaddrinfo with different default habits around IPv6.

What I tried, in order:

  1. Regenerated a self-signed cert and restarted the listener on the same port.
  2. Passed verify=False in the Python client, which still never connected.
  3. Opened port 8443 in iptables even though curl already succeeded locally.
  4. Copied the working curl command into subprocess, which of course still worked.
  5. Blamed urllib and then ssl.create_default_context for hiding the real errno.

If verify=False still fails before any TLS record is written, why are you still staring at certificates? That question should have ended hour two, and it did not.

Hours 8–24: commands that finally told the truth

The useful evidence was boring and local, which is usually a hint I ignore. I printed every tuple from socket.getaddrinfo and compared it with getent and ss. The dump is small enough to keep in a gist, and it is more honest than another certificate rotation.

import socket

def dump_addrinfo(host: str, port: int) -> None:
    infos = socket.getaddrinfo(
        host,
        port,
        type=socket.SOCK_STREAM,
    )
    for index, info in enumerate(infos):
        family, socktype, proto, canonname, sockaddr = info
        family_name = {
            socket.AF_INET: "AF_INET",
            socket.AF_INET6: "AF_INET6",
        }.get(family, str(family))
        print(f"{index}: {family_name} {sockaddr} proto={proto}")

if __name__ == "__main__":
    dump_addrinfo("localhost", 8443)
    dump_addrinfo("127.0.0.1", 8443)
Enter fullscreen mode Exit fullscreen mode

On that Linux box the first row for localhost was AF_INET6 ('::1', 8443, 0, 0). The listener I had started was bound only to 127.0.0.1:8443. curl had quietly used IPv4. Python had honored glibc address sorting and walked into ::1, where nothing accepted SYN packets.

Supporting commands I actually ran:

ss -ltn | grep 8443
getent ahosts localhost
getent ahosts 127.0.0.1
cat /etc/hosts
cat /etc/gai.conf 2>/dev/null || true
sysctl net.ipv6.conf.all.disable_ipv6
python3 dump_addrinfo.py
curl -g -v --http1.1 "https://127.0.0.1:8443/" -k
curl -g -v --http1.1 "https://[::1]:8443/" -k
Enter fullscreen mode Exit fullscreen mode

The last curl failed the same way Python failed, which is the moment the TLS story should die. If curl only looks healthy because it picked another family, your Python client is not being dramatic. It is being literal.

Hours 24–48: what actually broke

The server socket was IPv4-only. The name localhost was dual-stack. The client called getaddrinfo and connected to the first result without walking the rest of the list. Python's socket.create_connection does try the next address after a refusal, but my wrapper had taken infos[0] and opened a raw socket itself. Have you written that helper because copying the first tuple felt like simplicity? I have, more than once, and it keeps teaching the same lesson.

A minimal listener that recreates the trap (lab code, not a production server):

import socket
import ssl
from pathlib import Path

# Intentionally IPv4-only. Do not copy this bind into production.
listen = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listen.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listen.bind(("127.0.0.1", 8443))
listen.listen(5)

context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("cert.pem", "key.pem")

print("listening on 127.0.0.1:8443")
while True:
    raw, addr = listen.accept()
    tls = context.wrap_socket(raw, server_side=True)
    data = tls.recv(1024)
    tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nok\n")
    tls.close()
Enter fullscreen mode Exit fullscreen mode

A client that repeats my mistake:

import socket
import ssl

host = "localhost"
port = 8443
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
family, socktype, proto, _, sockaddr = infos[0]
raw = socket.socket(family, socktype, proto)
raw.settimeout(3)
raw.connect(sockaddr)  # this is ::1 on many Linux hosts
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
tls = ctx.wrap_socket(raw, server_hostname=host)
print(tls.version())
Enter fullscreen mode Exit fullscreen mode

socket.create_connection((host, port), timeout=3) would have walked the list. My "simple" helper would not. The failure mode is not "Python cannot do TLS." The failure mode is "I pinned the first address family and then narrated a certificate drama for two days."

Generate throwaway certs only for this lab, and keep them out of git:

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 2 -nodes -subj "/CN=localhost"
Enter fullscreen mode Exit fullscreen mode

Decision table I wish I had drawn at hour one

Observation Likely layer Next probe
curl works, Python fails before any TLS record address selection dump getaddrinfo vs ss -ltn
both fail on localhost, both work on 127.0.0.1 IPv6-first localhost curl -g https://[::1]:port/
both fail on ::1, server shows 0.0.0.0 or 127.0.0.1 bind family restart listener on :: or dual-stack
TCP connects, then TLS alerts certificate or SNI openssl s_client -connect 127.0.0.1:8443 -servername localhost
macOS works, Linux CI fails resolver / gai.conf / hosts reproduce on a clean Linux shell

If the table says address selection, stop rotating certificates. You are burning hours in the wrong museum, and the SYN never reached a bound port. Draw the table on paper if you have to, because a whiteboard beats another verify=False experiment.

Where a free remote shell and a free model actually helped

I could not trust my laptop because its localhost ordering did not match the Linux image. I wanted a disposable glibc environment, a copy of the two scripts above, and a second pair of eyes on the getaddrinfo dump. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free server option as that throwaway Linux shell, and I used its free model access to read the dump beside the ss output without pasting production hostnames.

I did not ask the model to "fix TLS." I pasted the sanitized table of families and asked which tuple the listener could possibly accept. The model is not an oracle, and it will happily invent a gai.conf stanza if you let it ramble. The value was narrower: keep me from rewriting certificates when the SYN never reached a bound port.

A prompt that stayed honest:

Here is getaddrinfo output and ss -ltn for port 8443.
No production hostnames. Which sockaddr can the listener accept?
Do not suggest TLS ciphers unless a TCP handshake already succeeds.
Enter fullscreen mode Exit fullscreen mode

If you strip every product name out of this article, the method remains: reproduce on the same resolver, print the tuples, and bind the family you actually intended. A free shell is only a convenience when the bug is environment-shaped. It is not a substitute for reading ss.

What I would repeat, and what I would not

I would repeat the dump script before I touch OpenSSL, every single time this smell returns. I would repeat binding dual-stack, or documenting IPv4-only as an explicit contract in the README. I would repeat using socket.create_connection instead of infos[0], because walking the list is the whole point of getaddrinfo. I would not repeat shipping a localhost demo that only binds 127.0.0.1 and then CI-testing it as localhost.

Things that still break if you copy this blindly:

  • A free remote shell will not match production nsswitch.conf, search domains, or corporate DNS.
  • Free model access can misread gai.conf precedence and quote stale blog posts as if they were man pages.
  • Dual-stack binds need IPV6_V6ONLY awareness; otherwise you think you opened two sockets and got one.
  • This workflow assumes you can publish sanitized scripts. If your resolver config is the secret, do not paste it.
  • localhost is not your production hostname. Reproducing on loopback does not prove SNI or certificate SANs.

Who should not use this approach:

  • Anyone who cannot put even sanitized snippets on a shared free server.
  • Anyone debugging Happy Eyeballs in a browser, which is a different client with a different timer.
  • Anyone whose listener already binds :: and whose failure is a real certificate hostname mismatch.
  • Anyone hoping a model will replace ss, getent, and a three-line dump script.

The 48 hours were not heroic. They were a reminder that curl is a different resolver conversation wearing a familiar URL. Next time the handshake "works in curl," I will ask one rude question first: which sockaddr did Python actually dial?

Top comments (0)